From 596b44afed568770d56f658f7acb7114845f9dcd Mon Sep 17 00:00:00 2001 From: Julin <142230457+c-julin@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:02:30 +0100 Subject: [PATCH 01/11] sql: render "record" composite columns with the JSON tree viewer [UX-1330] The backend now labels composite (struct) columns "record"/"record[]" instead of "json"/"json[]". Map those type strings to the json display kind so composite cells keep the nested JSON tree viewer and the catalog tree/cell popover show the "record" label. --- frontend/src/components/pages/sql/catalog-tree.test.tsx | 6 +++--- frontend/src/components/pages/sql/sql-types.test.ts | 7 ++++--- frontend/src/components/pages/sql/sql-types.ts | 7 ++++--- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/pages/sql/catalog-tree.test.tsx b/frontend/src/components/pages/sql/catalog-tree.test.tsx index 30e719752c..e481ae4531 100644 --- a/frontend/src/components/pages/sql/catalog-tree.test.tsx +++ b/frontend/src/components/pages/sql/catalog-tree.test.tsx @@ -98,7 +98,7 @@ describe('CatalogTree', () => { }); test('expanding a table lists its columns with type labels', async () => { - // Types arrive lower-cased from the backend; composite columns as "json" + // Types arrive lower-cased from the backend; composite columns as "record" // with their nested fields parsed server-side. vi.mocked(useDescribeTableQuery).mockReturnValue({ data: { @@ -106,7 +106,7 @@ describe('CatalogTree', () => { { name: 'id', type: 'bigint' }, { name: 'payload', type: 'jsonb' }, { name: 'tags', type: 'text[]' }, - { name: 'customer', type: 'json', fields: [{ name: 'street', type: 'text' }] }, + { name: 'customer', type: 'record', fields: [{ name: 'street', type: 'text' }] }, ], }, isLoading: false, @@ -119,7 +119,7 @@ describe('CatalogTree', () => { expect(screen.getByText('jsonb')).toBeInTheDocument(); expect(screen.getByText('text[]')).toBeInTheDocument(); - // Composite column shows "json" and expands into its nested fields. + // Composite column shows "record" and expands into its nested fields. const customerRow = screen.getByRole('button', { name: CUSTOMER_RE }); expect(customerRow).toBeInTheDocument(); expect(screen.queryByText('street')).not.toBeInTheDocument(); diff --git a/frontend/src/components/pages/sql/sql-types.test.ts b/frontend/src/components/pages/sql/sql-types.test.ts index 19f68d409d..1056be12b9 100644 --- a/frontend/src/components/pages/sql/sql-types.test.ts +++ b/frontend/src/components/pages/sql/sql-types.test.ts @@ -28,9 +28,10 @@ describe('columnKindForPgType', () => { ['JSONB', 'json'], ['TEXT', 'str'], ['UNKNOWN_TYPE', 'str'], - // Composite columns arrive pre-labelled as "json"/"json[]" from the backend. - ['json', 'json'], - ['json[]', 'json'], + // Composite columns arrive pre-labelled as "record"/"record[]" from the + // backend and render with the JSON tree viewer. + ['record', 'json'], + ['record[]', 'json'], ] as const)('%s → %s', (pgType, kind) => { expect(columnKindForPgType(pgType)).toBe(kind); }); diff --git a/frontend/src/components/pages/sql/sql-types.ts b/frontend/src/components/pages/sql/sql-types.ts index 6ed007962f..d65b77ce3c 100644 --- a/frontend/src/components/pages/sql/sql-types.ts +++ b/frontend/src/components/pages/sql/sql-types.ts @@ -133,8 +133,8 @@ export function isArrayPgType(pgType: string): boolean { } // Maps a Postgres type name to a display kind. Composite columns arrive as the -// literal "json"/"json[]" (the backend parses structure into Column.fields), so -// they fall through to the JSON branch. Arrays map to their element kind; +// literal "record"/"record[]" (the backend parses structure into Column.fields) +// and render with the JSON tree viewer. Arrays map to their element kind; // anything unrecognized defaults to a string. export function columnKindForPgType(pgType: string): ColumnKind { const element = arrayElementPgType(pgType); @@ -152,7 +152,8 @@ export function columnKindForPgType(pgType: string): ColumnKind { if (BOOL_TYPE.test(t)) { return 'bool'; } - if (t.includes('JSON')) { + // RECORD/RECORD[] are composite columns; JSON kept for any real json scalar. + if (t.includes('RECORD') || t.includes('JSON')) { return 'json'; } return 'str'; From faea0bd1404ecb4e9f17e7672a56156616ab7c6d Mon Sep 17 00:00:00 2001 From: Julin <142230457+c-julin@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:33:34 +0100 Subject: [PATCH 02/11] sql: show the query-error hint on its own line [UX-1330] The backend now appends an actionable hint after a blank line. Split it off the error message and render it in the existing styled hint slot below the alert, instead of inline in the message body. --- .../src/components/pages/sql/sql-types.test.ts | 16 +++++++++++++++- frontend/src/components/pages/sql/sql-types.ts | 8 ++++++++ .../src/components/pages/sql/sql-workspace.tsx | 4 +++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/pages/sql/sql-types.test.ts b/frontend/src/components/pages/sql/sql-types.test.ts index 1056be12b9..de321898d3 100644 --- a/frontend/src/components/pages/sql/sql-types.test.ts +++ b/frontend/src/components/pages/sql/sql-types.test.ts @@ -11,7 +11,7 @@ import { describe, expect, test } from 'vitest'; -import { arrayElementPgType, columnKindForPgType, isArrayPgType } from './sql-types'; +import { arrayElementPgType, columnKindForPgType, isArrayPgType, splitQueryError } from './sql-types'; describe('columnKindForPgType', () => { test.each([ @@ -63,3 +63,17 @@ describe('arrayElementPgType', () => { expect(isArrayPgType('TEXT[]')).toBe(true); }); }); + +describe('splitQueryError', () => { + test('splits the trailing hint onto its own field', () => { + const { message, hint } = splitQueryError("operator does not exist: record -> unknown\n\nHint: use (user).id"); + expect(message).toBe('operator does not exist: record -> unknown'); + expect(hint).toBe('use (user).id'); + }); + + test('leaves a hintless message intact', () => { + const { message, hint } = splitQueryError('syntax error at or near "SELCT"'); + expect(message).toBe('syntax error at or near "SELCT"'); + expect(hint).toBeUndefined(); + }); +}); diff --git a/frontend/src/components/pages/sql/sql-types.ts b/frontend/src/components/pages/sql/sql-types.ts index d65b77ce3c..c8a3ca78e0 100644 --- a/frontend/src/components/pages/sql/sql-types.ts +++ b/frontend/src/components/pages/sql/sql-types.ts @@ -159,6 +159,14 @@ export function columnKindForPgType(pgType: string): ColumnKind { return 'str'; } +// The backend appends an actionable hint after a blank line ("\n\nHint: …"); +// split it off so it can render on its own line instead of inline in the message. +export function splitQueryError(message: string): { message: string; hint?: string } { + const sep = '\n\nHint: '; + const i = message.indexOf(sep); + return i === -1 ? { message } : { message: message.slice(0, i), hint: message.slice(i + sep.length) }; +} + // Word-boundary anchored so geometric/temporal names that merely contain a // numeric token (POINT → INT, INTERVAL → INT) don't get misread as numeric. const NUMERIC_TYPE = /\b(?:INT|INTEGER|SMALLINT|BIGINT|FLOAT|NUMERIC|DECIMAL|DOUBLE|REAL|SERIAL|MONEY)/; diff --git a/frontend/src/components/pages/sql/sql-workspace.tsx b/frontend/src/components/pages/sql/sql-workspace.tsx index 0d71a27b8b..8a7e26fda9 100644 --- a/frontend/src/components/pages/sql/sql-workspace.tsx +++ b/frontend/src/components/pages/sql/sql-workspace.tsx @@ -53,6 +53,7 @@ import { isArrayPgType, type QueryRun, type ResultRow, + splitQueryError, type SqlRole, type TableRef, } from './sql-types'; @@ -546,7 +547,8 @@ export function SqlWorkspace({ sqlRole: sqlRoleProp }: SqlWorkspaceProps) { if (latestRunToken.current !== token) { return; } - setRun({ state: 'error', token, title: 'Query failed', message: error.message }); + const { message, hint } = splitQueryError(error.message); + setRun({ state: 'error', token, title: 'Query failed', message, hint }); }, }); }, From b86969cc16cf297726a72902c0db01273f653427 Mon Sep 17 00:00:00 2001 From: Julin <142230457+c-julin@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:45:44 +0100 Subject: [PATCH 03/11] sql: render the query-error hint as a labelled info callout [UX-1330] Show the hint in an info Alert with a lightbulb icon and a "Hint" title instead of loose muted text, so it reads clearly as guidance rather than part of the error. --- .../src/components/pages/sql/sql-results.tsx | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/pages/sql/sql-results.tsx b/frontend/src/components/pages/sql/sql-results.tsx index f40fea2ecf..3c74a71514 100644 --- a/frontend/src/components/pages/sql/sql-results.tsx +++ b/frontend/src/components/pages/sql/sql-results.tsx @@ -28,7 +28,7 @@ import { Spinner } from 'components/redpanda-ui/components/spinner'; import { StatusDot } from 'components/redpanda-ui/components/status-dot'; import { InlineCode, Text } from 'components/redpanda-ui/components/typography'; import { cn } from 'components/redpanda-ui/lib/utils'; -import { Braces, CircleX, Clock, Database, Download, GitMerge, Plus, Rows3, Terminal, X } from 'lucide-react'; +import { Braces, CircleX, Clock, Database, Download, GitMerge, Lightbulb, Plus, Rows3, Terminal, X } from 'lucide-react'; import { createContext, useContext, useMemo, useState } from 'react'; import DataGrid, { type Column } from 'react-data-grid'; import { isMacOS } from 'utils/platform'; @@ -476,14 +476,17 @@ export function SqlResults({ run, sqlRole, onAddTable, hasTables = true }: SqlRe {run.message} {run.hint ? ( -
- {run.hint} - {run.hintAction && sqlRole === 'admin' && onAddTable ? ( - - ) : null} -
+ } variant="info"> + Hint + + {run.hint} + {run.hintAction && sqlRole === 'admin' && onAddTable ? ( + + ) : null} + + ) : null} ); From 34d354a4637df1fc550c5281a50ce2e2ab2d13ba Mon Sep 17 00:00:00 2001 From: Julin <142230457+c-julin@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:51:57 +0100 Subject: [PATCH 04/11] style(sql): apply ultracite lint fixes (import order, formatting) [UX-1330] --- frontend/src/components/pages/sql/sql-results.tsx | 14 +++++++++++++- .../src/components/pages/sql/sql-types.test.ts | 2 +- .../src/components/pages/sql/sql-workspace.tsx | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/pages/sql/sql-results.tsx b/frontend/src/components/pages/sql/sql-results.tsx index 3c74a71514..76ed2777bf 100644 --- a/frontend/src/components/pages/sql/sql-results.tsx +++ b/frontend/src/components/pages/sql/sql-results.tsx @@ -28,7 +28,19 @@ import { Spinner } from 'components/redpanda-ui/components/spinner'; import { StatusDot } from 'components/redpanda-ui/components/status-dot'; import { InlineCode, Text } from 'components/redpanda-ui/components/typography'; import { cn } from 'components/redpanda-ui/lib/utils'; -import { Braces, CircleX, Clock, Database, Download, GitMerge, Lightbulb, Plus, Rows3, Terminal, X } from 'lucide-react'; +import { + Braces, + CircleX, + Clock, + Database, + Download, + GitMerge, + Lightbulb, + Plus, + Rows3, + Terminal, + X, +} from 'lucide-react'; import { createContext, useContext, useMemo, useState } from 'react'; import DataGrid, { type Column } from 'react-data-grid'; import { isMacOS } from 'utils/platform'; diff --git a/frontend/src/components/pages/sql/sql-types.test.ts b/frontend/src/components/pages/sql/sql-types.test.ts index de321898d3..78404105aa 100644 --- a/frontend/src/components/pages/sql/sql-types.test.ts +++ b/frontend/src/components/pages/sql/sql-types.test.ts @@ -66,7 +66,7 @@ describe('arrayElementPgType', () => { describe('splitQueryError', () => { test('splits the trailing hint onto its own field', () => { - const { message, hint } = splitQueryError("operator does not exist: record -> unknown\n\nHint: use (user).id"); + const { message, hint } = splitQueryError('operator does not exist: record -> unknown\n\nHint: use (user).id'); expect(message).toBe('operator does not exist: record -> unknown'); expect(hint).toBe('use (user).id'); }); diff --git a/frontend/src/components/pages/sql/sql-workspace.tsx b/frontend/src/components/pages/sql/sql-workspace.tsx index 8a7e26fda9..c3d2853d6f 100644 --- a/frontend/src/components/pages/sql/sql-workspace.tsx +++ b/frontend/src/components/pages/sql/sql-workspace.tsx @@ -53,8 +53,8 @@ import { isArrayPgType, type QueryRun, type ResultRow, - splitQueryError, type SqlRole, + splitQueryError, type TableRef, } from './sql-types'; import { createTableSql, SqlWizard, type WizardTopic } from './sql-wizard'; From e06d1643648dd4e45899c5f8a7fd64b1f7874a11 Mon Sep 17 00:00:00 2001 From: Julin <142230457+c-julin@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:41:42 +0100 Subject: [PATCH 05/11] feat(sql): read record-column hint from structured ErrorInfo metadata [UX-1330] --- .../components/pages/sql/sql-types.test.ts | 22 ++++++++++++++++++- .../src/components/pages/sql/sql-types.ts | 17 ++++++++++++++ .../components/pages/sql/sql-workspace.tsx | 5 ++++- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/pages/sql/sql-types.test.ts b/frontend/src/components/pages/sql/sql-types.test.ts index 78404105aa..265a185c31 100644 --- a/frontend/src/components/pages/sql/sql-types.test.ts +++ b/frontend/src/components/pages/sql/sql-types.test.ts @@ -9,9 +9,12 @@ * by the Apache License, Version 2.0 */ +import { create } from '@bufbuild/protobuf'; +import { ConnectError } from '@connectrpc/connect'; +import { ErrorInfoSchema } from 'protogen/google/rpc/error_details_pb'; import { describe, expect, test } from 'vitest'; -import { arrayElementPgType, columnKindForPgType, isArrayPgType, splitQueryError } from './sql-types'; +import { arrayElementPgType, columnKindForPgType, hintFromError, isArrayPgType, splitQueryError } from './sql-types'; describe('columnKindForPgType', () => { test.each([ @@ -77,3 +80,20 @@ describe('splitQueryError', () => { expect(hint).toBeUndefined(); }); }); + +describe('hintFromError', () => { + test('reads the hint from ErrorInfo metadata', () => { + const error = new ConnectError('boom', undefined, undefined, [ + { + desc: ErrorInfoSchema, + value: create(ErrorInfoSchema, { reason: 'REASON_INVALID_INPUT', metadata: { hint: 'use (customer).id' } }), + }, + ]); + expect(hintFromError(error)).toBe('use (customer).id'); + }); + + test('returns undefined when there is no ErrorInfo hint', () => { + expect(hintFromError(new ConnectError('boom'))).toBeUndefined(); + expect(hintFromError(new Error('plain'))).toBeUndefined(); + }); +}); diff --git a/frontend/src/components/pages/sql/sql-types.ts b/frontend/src/components/pages/sql/sql-types.ts index c8a3ca78e0..0c840c3712 100644 --- a/frontend/src/components/pages/sql/sql-types.ts +++ b/frontend/src/components/pages/sql/sql-types.ts @@ -9,6 +9,9 @@ * by the Apache License, Version 2.0 */ +import { ConnectError } from '@connectrpc/connect'; +import { ErrorInfoSchema } from 'protogen/google/rpc/error_details_pb'; + // Shared types for the SQL workspace, so the leaf components (catalog tree, // editor, results) and the data layer in sql-workspace agree on shape without // importing from each other. @@ -167,6 +170,20 @@ export function splitQueryError(message: string): { message: string; hint?: stri return i === -1 ? { message } : { message: message.slice(0, i), hint: message.slice(i + sep.length) }; } +// Structured hint from the Connect error's ErrorInfo metadata. Preferred over the +// message-string fallback in splitQueryError — same text, but read from a typed +// detail instead of parsed out of prose. undefined when absent. +export function hintFromError(error: unknown): string | undefined { + if (error instanceof ConnectError) { + for (const info of error.findDetails(ErrorInfoSchema)) { + if (info.metadata.hint) { + return info.metadata.hint; + } + } + } + return; +} + // Word-boundary anchored so geometric/temporal names that merely contain a // numeric token (POINT → INT, INTERVAL → INT) don't get misread as numeric. const NUMERIC_TYPE = /\b(?:INT|INTEGER|SMALLINT|BIGINT|FLOAT|NUMERIC|DECIMAL|DOUBLE|REAL|SERIAL|MONEY)/; diff --git a/frontend/src/components/pages/sql/sql-workspace.tsx b/frontend/src/components/pages/sql/sql-workspace.tsx index c3d2853d6f..f6fa8764aa 100644 --- a/frontend/src/components/pages/sql/sql-workspace.tsx +++ b/frontend/src/components/pages/sql/sql-workspace.tsx @@ -50,6 +50,7 @@ import { type CellValue, type ColumnDef, columnKindForPgType, + hintFromError, isArrayPgType, type QueryRun, type ResultRow, @@ -547,7 +548,9 @@ export function SqlWorkspace({ sqlRole: sqlRoleProp }: SqlWorkspaceProps) { if (latestRunToken.current !== token) { return; } - const { message, hint } = splitQueryError(error.message); + const { message, hint: messageHint } = splitQueryError(error.message); + // Prefer the structured ErrorInfo hint; fall back to the message string. + const hint = hintFromError(error) ?? messageHint; setRun({ state: 'error', token, title: 'Query failed', message, hint }); }, }); From 1d62795472954a7f429502c85c62d23b304e0882 Mon Sep 17 00:00:00 2001 From: Julin <142230457+c-julin@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:46:49 +0100 Subject: [PATCH 06/11] refactor(sql): drop message-string hint parsing, read structured metadata only [UX-1330] --- .../src/components/pages/sql/sql-types.test.ts | 16 +--------------- frontend/src/components/pages/sql/sql-types.ts | 12 +----------- .../src/components/pages/sql/sql-workspace.tsx | 6 +----- 3 files changed, 3 insertions(+), 31 deletions(-) diff --git a/frontend/src/components/pages/sql/sql-types.test.ts b/frontend/src/components/pages/sql/sql-types.test.ts index 265a185c31..9e4def5dc1 100644 --- a/frontend/src/components/pages/sql/sql-types.test.ts +++ b/frontend/src/components/pages/sql/sql-types.test.ts @@ -14,7 +14,7 @@ import { ConnectError } from '@connectrpc/connect'; import { ErrorInfoSchema } from 'protogen/google/rpc/error_details_pb'; import { describe, expect, test } from 'vitest'; -import { arrayElementPgType, columnKindForPgType, hintFromError, isArrayPgType, splitQueryError } from './sql-types'; +import { arrayElementPgType, columnKindForPgType, hintFromError, isArrayPgType } from './sql-types'; describe('columnKindForPgType', () => { test.each([ @@ -67,20 +67,6 @@ describe('arrayElementPgType', () => { }); }); -describe('splitQueryError', () => { - test('splits the trailing hint onto its own field', () => { - const { message, hint } = splitQueryError('operator does not exist: record -> unknown\n\nHint: use (user).id'); - expect(message).toBe('operator does not exist: record -> unknown'); - expect(hint).toBe('use (user).id'); - }); - - test('leaves a hintless message intact', () => { - const { message, hint } = splitQueryError('syntax error at or near "SELCT"'); - expect(message).toBe('syntax error at or near "SELCT"'); - expect(hint).toBeUndefined(); - }); -}); - describe('hintFromError', () => { test('reads the hint from ErrorInfo metadata', () => { const error = new ConnectError('boom', undefined, undefined, [ diff --git a/frontend/src/components/pages/sql/sql-types.ts b/frontend/src/components/pages/sql/sql-types.ts index 0c840c3712..6cdc29a3fc 100644 --- a/frontend/src/components/pages/sql/sql-types.ts +++ b/frontend/src/components/pages/sql/sql-types.ts @@ -162,17 +162,7 @@ export function columnKindForPgType(pgType: string): ColumnKind { return 'str'; } -// The backend appends an actionable hint after a blank line ("\n\nHint: …"); -// split it off so it can render on its own line instead of inline in the message. -export function splitQueryError(message: string): { message: string; hint?: string } { - const sep = '\n\nHint: '; - const i = message.indexOf(sep); - return i === -1 ? { message } : { message: message.slice(0, i), hint: message.slice(i + sep.length) }; -} - -// Structured hint from the Connect error's ErrorInfo metadata. Preferred over the -// message-string fallback in splitQueryError — same text, but read from a typed -// detail instead of parsed out of prose. undefined when absent. +// Structured hint from the Connect error's ErrorInfo metadata. undefined when absent. export function hintFromError(error: unknown): string | undefined { if (error instanceof ConnectError) { for (const info of error.findDetails(ErrorInfoSchema)) { diff --git a/frontend/src/components/pages/sql/sql-workspace.tsx b/frontend/src/components/pages/sql/sql-workspace.tsx index f6fa8764aa..367e7bc437 100644 --- a/frontend/src/components/pages/sql/sql-workspace.tsx +++ b/frontend/src/components/pages/sql/sql-workspace.tsx @@ -55,7 +55,6 @@ import { type QueryRun, type ResultRow, type SqlRole, - splitQueryError, type TableRef, } from './sql-types'; import { createTableSql, SqlWizard, type WizardTopic } from './sql-wizard'; @@ -548,10 +547,7 @@ export function SqlWorkspace({ sqlRole: sqlRoleProp }: SqlWorkspaceProps) { if (latestRunToken.current !== token) { return; } - const { message, hint: messageHint } = splitQueryError(error.message); - // Prefer the structured ErrorInfo hint; fall back to the message string. - const hint = hintFromError(error) ?? messageHint; - setRun({ state: 'error', token, title: 'Query failed', message, hint }); + setRun({ state: 'error', token, title: 'Query failed', message: error.message, hint: hintFromError(error) }); }, }); }, From 663614ba5ccac277daf0f207ba66cf8f2faca8c9 Mon Sep 17 00:00:00 2001 From: Julin <142230457+c-julin@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:51:30 +0100 Subject: [PATCH 07/11] refactor(sql): drop query success/error toasts in favor of inline feedback [UX-1330] --- frontend/src/components/pages/sql/sql-workspace.tsx | 2 -- frontend/src/react-query/api/sql.tsx | 8 ++------ 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/pages/sql/sql-workspace.tsx b/frontend/src/components/pages/sql/sql-workspace.tsx index 367e7bc437..3efeb9e656 100644 --- a/frontend/src/components/pages/sql/sql-workspace.tsx +++ b/frontend/src/components/pages/sql/sql-workspace.tsx @@ -36,7 +36,6 @@ import { useTopicIcebergQuery, } from 'react-query/api/sql'; import { useLegacyListTopicsQuery } from 'react-query/api/topic'; -import { toast } from 'sonner'; import { Feature, isSupported, useSupportedFeaturesStore } from 'state/supported-features'; import { uiState } from 'state/ui-state'; @@ -608,7 +607,6 @@ export function SqlWorkspace({ sqlRole: sqlRoleProp }: SqlWorkspaceProps) { executeQuery.mutate(create(ExecuteQueryRequestSchema, { statement }), { onSuccess: async () => { await invalidateSqlCatalog(); - toast.success(`Table ${tableName} created`); closeWizard(); }, onError: (error) => setWizardError(error.message), diff --git a/frontend/src/react-query/api/sql.tsx b/frontend/src/react-query/api/sql.tsx index 8254c748c3..fab4a4ea93 100644 --- a/frontend/src/react-query/api/sql.tsx +++ b/frontend/src/react-query/api/sql.tsx @@ -31,8 +31,6 @@ import { listTables, } from 'protogen/redpanda/api/dataplane/v1alpha3/sql-SQLService_connectquery'; import { MAX_PAGE_SIZE, type MessageInit } from 'react-query/react-query.utils'; -import { toast } from 'sonner'; -import { formatToastErrorMessageGRPC } from 'utils/toast.utils'; type SqlQueryOptions = { enabled?: boolean; @@ -94,10 +92,8 @@ export const useTopicIcebergQuery = (topicName: string, options?: SqlQueryOption return { ...result, isIceberg: Boolean(mode && mode !== 'disabled') }; }; -export const useExecuteQueryMutation = () => - useMutation(executeQuery, { - onError: (error) => toast.error(formatToastErrorMessageGRPC({ error, action: 'execute', entity: 'SQL query' })), - }); +// Errors surface inline (run panel / wizard), so no toast on failure. +export const useExecuteQueryMutation = () => useMutation(executeQuery); // Returns a function that refreshes the catalog/table listings, e.g. after a // CREATE TABLE so the new table shows up in the tree. From bab34455d4ba145d4448c28bb8ee9c858682b746 Mon Sep 17 00:00:00 2001 From: Julin <142230457+c-julin@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:53:20 +0100 Subject: [PATCH 08/11] refactor(sql): keep table-created success toast [UX-1330] --- frontend/src/components/pages/sql/sql-workspace.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/components/pages/sql/sql-workspace.tsx b/frontend/src/components/pages/sql/sql-workspace.tsx index 3efeb9e656..367e7bc437 100644 --- a/frontend/src/components/pages/sql/sql-workspace.tsx +++ b/frontend/src/components/pages/sql/sql-workspace.tsx @@ -36,6 +36,7 @@ import { useTopicIcebergQuery, } from 'react-query/api/sql'; import { useLegacyListTopicsQuery } from 'react-query/api/topic'; +import { toast } from 'sonner'; import { Feature, isSupported, useSupportedFeaturesStore } from 'state/supported-features'; import { uiState } from 'state/ui-state'; @@ -607,6 +608,7 @@ export function SqlWorkspace({ sqlRole: sqlRoleProp }: SqlWorkspaceProps) { executeQuery.mutate(create(ExecuteQueryRequestSchema, { statement }), { onSuccess: async () => { await invalidateSqlCatalog(); + toast.success(`Table ${tableName} created`); closeWizard(); }, onError: (error) => setWizardError(error.message), From 0233d024467b52c1ee293ac2d67c0bbc2e4be256 Mon Sep 17 00:00:00 2001 From: Julin <142230457+c-julin@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:09:17 +0100 Subject: [PATCH 09/11] fix(sql): scope autocomplete table names to the selected catalog --- .../src/components/pages/sql/sql-editor.tsx | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/pages/sql/sql-editor.tsx b/frontend/src/components/pages/sql/sql-editor.tsx index c1198f5a1d..d8e9d78e6f 100644 --- a/frontend/src/components/pages/sql/sql-editor.tsx +++ b/frontend/src/components/pages/sql/sql-editor.tsx @@ -15,7 +15,7 @@ import { type CompletionResult, startCompletion, } from '@codemirror/autocomplete'; -import { PostgreSQL, type SQLNamespace, sql as sqlLanguage } from '@codemirror/lang-sql'; +import { PostgreSQL, type SQLNamespace, schemaCompletionSource, sql as sqlLanguage } from '@codemirror/lang-sql'; import { HighlightStyle, indentUnit, syntaxHighlighting, syntaxTree } from '@codemirror/language'; import { EditorState, type Extension, Prec } from '@codemirror/state'; import { EditorView, keymap } from '@codemirror/view'; @@ -192,12 +192,9 @@ function tableNamespace(table: TableRef): SQLNamespace { }; } -// Builds the lang-sql completion schema from the loaded catalog tree: bare -// table names → columns. Tables are deliberately NOT nested under their -// catalog — Redpanda SQL (Oxla) addresses catalog tables with arrow notation -// (`catalog=>table`), which catalogArrowSource below handles; dot-style -// nesting would advertise syntax the server rejects. Bare entries still give -// alias/column resolution (`FROM default_redpanda_catalog=>cars c` → `c.`). +// Bare table name → columns. Powers alias/column resolution only +// (schemaColumnSource); tables aren't nested under catalogs since Oxla uses +// `catalog=>table` arrow notation, handled by catalogArrowSource. function buildSchema(catalogs: Catalog[]): SQLNamespace { const root: Record = {}; for (const catalog of catalogs) { @@ -212,6 +209,20 @@ function buildSchema(catalogs: Catalog[]): SQLNamespace { return root; } +// Schema completion limited to dotted members (`c.` → columns); bare table +// names are suppressed at the top level so catalogArrowSource owns them. +function schemaColumnSource(catalogs: Catalog[]): (context: CompletionContext) => CompletionResult | null { + const source = schemaCompletionSource({ dialect: PostgreSQL, schema: buildSchema(catalogs) }); + return (context) => { + const result = source(context); + if (!result || result instanceof Promise) { + return null; + } + const dotted = context.state.sliceDoc(result.from - 1, result.from) === '.'; + return dotted ? result : null; + }; +} + // Matches an identifier followed by `=>` or `.` and a partial table name, // anchored at the cursor: [, name, gap1, separator, gap2, quote, partial]. const CATALOG_REF_RE = /([A-Za-z_][\w$]*)(\s*)(=>|\.)(\s*)("?)([\w$]*)$/; @@ -415,7 +426,9 @@ export const SqlEditor = forwardRef( }; const extensions = useMemo(() => { - const sqlSupport = sqlLanguage({ dialect: PostgreSQL, schema: buildSchema(catalogs), upperCaseKeywords: true }); + // No `schema` here — schemaColumnSource adds it back for dotted + // completions only, avoiding bare table names at the top level. + const sqlSupport = sqlLanguage({ dialect: PostgreSQL, upperCaseKeywords: true }); return [ // Prec.highest so Mod-Enter beats the default keymap's insertBlankLine. Prec.highest( @@ -441,6 +454,7 @@ export const SqlEditor = forwardRef( ), sqlSupport, sqlSupport.language.data.of({ autocomplete: catalogArrowSource(catalogs) }), + sqlSupport.language.data.of({ autocomplete: schemaColumnSource(catalogs) }), isDark ? DARK_THEME : LIGHT_THEME, EditorView.updateListener.of((update) => { if (update.selectionSet) { From 74a539e119221d910d0e129352aef983d4aa9709 Mon Sep 17 00:00:00 2001 From: Julin <142230457+c-julin@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:23:24 +0100 Subject: [PATCH 10/11] style(sql): map editor syntax palette to semantic theme tokens --- .../src/components/pages/sql/sql-editor.tsx | 45 +++++++------------ 1 file changed, 16 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/pages/sql/sql-editor.tsx b/frontend/src/components/pages/sql/sql-editor.tsx index d8e9d78e6f..eef2b723d5 100644 --- a/frontend/src/components/pages/sql/sql-editor.tsx +++ b/frontend/src/components/pages/sql/sql-editor.tsx @@ -142,42 +142,29 @@ function editorChrome(mode: 'light' | 'dark'): Extension { ); } -// SQL syntax palette, mapped from the design's `.sql-*` token classes onto the -// Lezer highlight tags the SQL grammar emits (keywords, built-ins, strings, -// numbers, comments, operators/punctuation and identifiers). +// SQL syntax palette mapped onto the Lezer highlight tags the SQL grammar +// emits. Hued tokens use theme-adaptive semantic tokens; only the grey +// comment/identifier shades vary by mode. function sqlHighlight(mode: 'light' | 'dark'): Extension { - const c = + const grey = mode === 'dark' - ? { - keyword: 'var(--color-purple-300)', - fn: 'var(--color-indigo-300)', - str: 'var(--color-green-300)', - num: 'var(--color-orange-300)', - comment: 'var(--color-grey-400)', - punct: 'var(--color-grey-500)', - id: 'var(--color-grey-100)', - } - : { - keyword: 'var(--color-purple-700)', - fn: 'var(--color-indigo-600)', - str: 'var(--color-green-700)', - num: 'var(--color-orange-700)', - comment: 'var(--color-grey-600)', - punct: 'var(--color-grey-500)', - id: 'var(--color-grey-900)', - }; + ? { comment: 'var(--color-grey-400)', id: 'var(--color-grey-100)' } + : { comment: 'var(--color-grey-600)', id: 'var(--color-grey-900)' }; return syntaxHighlighting( HighlightStyle.define([ - { tag: tags.keyword, color: c.keyword, fontWeight: 'bold' }, - { tag: [tags.standard(tags.name), tags.function(tags.variableName), tags.typeName], color: c.fn }, - { tag: [tags.string, tags.special(tags.string)], color: c.str }, - { tag: tags.number, color: c.num }, - { tag: tags.comment, color: c.comment, fontStyle: 'italic' }, + { tag: tags.keyword, color: 'var(--color-secondary)', fontWeight: 'bold' }, + { + tag: [tags.standard(tags.name), tags.function(tags.variableName), tags.typeName], + color: 'var(--color-primary)', + }, + { tag: [tags.string, tags.special(tags.string)], color: 'var(--color-success)' }, + { tag: tags.number, color: 'var(--color-warning)' }, + { tag: tags.comment, color: grey.comment, fontStyle: 'italic' }, { tag: [tags.operator, tags.punctuation, tags.separator, tags.paren, tags.brace, tags.squareBracket], - color: c.punct, + color: 'var(--color-grey-500)', }, - { tag: tags.name, color: c.id }, + { tag: tags.name, color: grey.id }, ]) ); } From a4fe3fe5629329fa54a8c656de2b1e99fe4f22d1 Mon Sep 17 00:00:00 2001 From: Julin <142230457+c-julin@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:32:32 +0100 Subject: [PATCH 11/11] style(sql): move editor gutter, syntax greys and active line to semantic tokens --- .../src/components/pages/sql/sql-editor.tsx | 30 +++++++------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/pages/sql/sql-editor.tsx b/frontend/src/components/pages/sql/sql-editor.tsx index eef2b723d5..b34ef6009f 100644 --- a/frontend/src/components/pages/sql/sql-editor.tsx +++ b/frontend/src/components/pages/sql/sql-editor.tsx @@ -121,8 +121,6 @@ function useIsDarkMode(): boolean { // muted gutter line numbers. CodeMirror themes are plain CSS, so registry // custom properties can be referenced directly and stay live. function editorChrome(mode: 'light' | 'dark'): Extension { - const gutter = mode === 'dark' ? 'var(--color-grey-600)' : 'var(--color-grey-400)'; - const gutterActive = mode === 'dark' ? 'var(--color-grey-400)' : 'var(--color-grey-600)'; return EditorView.theme( { '&': { backgroundColor: 'transparent', height: '100%', fontSize: '13px' }, @@ -132,24 +130,18 @@ function editorChrome(mode: 'light' | 'dark'): Extension { lineHeight: '21px', }, '.cm-content': { padding: '12px 0' }, - '.cm-gutters': { backgroundColor: 'transparent', border: 'none', color: gutter }, - '.cm-activeLineGutter': { backgroundColor: 'transparent', color: gutterActive }, - '.cm-activeLine': { - backgroundColor: mode === 'dark' ? 'rgba(255, 255, 255, 0.04)' : 'rgba(0, 0, 0, 0.03)', - }, + '.cm-gutters': { backgroundColor: 'transparent', border: 'none', color: 'var(--color-muted-foreground)' }, + '.cm-activeLineGutter': { backgroundColor: 'transparent', color: 'var(--color-foreground)' }, + '.cm-activeLine': { backgroundColor: 'var(--color-surface-default-hover)' }, }, { dark: mode === 'dark' } ); } // SQL syntax palette mapped onto the Lezer highlight tags the SQL grammar -// emits. Hued tokens use theme-adaptive semantic tokens; only the grey -// comment/identifier shades vary by mode. -function sqlHighlight(mode: 'light' | 'dark'): Extension { - const grey = - mode === 'dark' - ? { comment: 'var(--color-grey-400)', id: 'var(--color-grey-100)' } - : { comment: 'var(--color-grey-600)', id: 'var(--color-grey-900)' }; +// emits, entirely from theme-adaptive semantic tokens so it tracks light/dark +// without per-mode values. +function sqlHighlight(): Extension { return syntaxHighlighting( HighlightStyle.define([ { tag: tags.keyword, color: 'var(--color-secondary)', fontWeight: 'bold' }, @@ -159,18 +151,18 @@ function sqlHighlight(mode: 'light' | 'dark'): Extension { }, { tag: [tags.string, tags.special(tags.string)], color: 'var(--color-success)' }, { tag: tags.number, color: 'var(--color-warning)' }, - { tag: tags.comment, color: grey.comment, fontStyle: 'italic' }, + { tag: tags.comment, color: 'var(--color-muted-foreground)', fontStyle: 'italic' }, { tag: [tags.operator, tags.punctuation, tags.separator, tags.paren, tags.brace, tags.squareBracket], - color: 'var(--color-grey-500)', + color: 'var(--color-muted-foreground)', }, - { tag: tags.name, color: grey.id }, + { tag: tags.name, color: 'var(--color-foreground)' }, ]) ); } -const LIGHT_THEME: Extension = [editorChrome('light'), sqlHighlight('light')]; -const DARK_THEME: Extension = [editorChrome('dark'), sqlHighlight('dark')]; +const LIGHT_THEME: Extension = [editorChrome('light'), sqlHighlight()]; +const DARK_THEME: Extension = [editorChrome('dark'), sqlHighlight()]; function tableNamespace(table: TableRef): SQLNamespace { return {