Skip to content

Commit 19c1610

Browse files
committed
refactor(sql): route-based views, load/error fidelity, shared history
- /sql is now a layout with real child routes: the landing at /sql and the editor at /sql/editor with seed/run/wizard search params — deep-linkable, back-button friendly, no sessionStorage view state - SQLService fallback redirect moved to the layout so it covers the landing - useSqlCatalogs tracks catalogs+tables+identity loading and exposes isError/refetch; the landing renders distinct loading and error states instead of flashing the empty-state onboarding, and only claims "Connected" after a successful load - query history extracted to sql-history.ts (one key+schema for editor and landing, cluster-scoped when embedded); landing-initiated auto-runs go through the editor's run path so they're recorded - landing rebuilt on registry Card/Spinner/Button primitives; shared previewSql helper; table-id keys; prettyMilliseconds timestamps - catalog/identity/table queries get a long staleTime (invalidation stays the freshness trigger); wizard topic listing fetches only when the wizard opens; SqlEditorView pass-through inlined; dark-border remap defined once
1 parent 1cfbf54 commit 19c1610

16 files changed

Lines changed: 728 additions & 537 deletions

frontend/src/components/pages/sql/sql-editor.test.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99
* by the Apache License, Version 2.0
1010
*/
1111

12-
import { fireEvent, render, screen } from '@testing-library/react';
12+
import { act, fireEvent, render, screen } from '@testing-library/react';
13+
import { createRef } from 'react';
1314
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
1415

15-
import { SqlEditor } from './sql-editor';
16+
import { SqlEditor, type SqlEditorHandle } from './sql-editor';
1617

1718
// CodeMirror's layout/measure loop doesn't run in jsdom; the editor surface is
1819
// exercised manually/e2e.
@@ -99,4 +100,16 @@ describe('SqlEditor', () => {
99100
fireEvent.click(screen.getByRole('button', { name: 'History' }));
100101
expect(await screen.findByText('No queries yet')).toBeInTheDocument();
101102
});
103+
104+
test('imperative run() executes and records history like a user run', async () => {
105+
const onRun = vi.fn();
106+
const ref = createRef<SqlEditorHandle>();
107+
render(<SqlEditor catalogs={[]} initialQuery="SELECT 1;" onRun={onRun} ref={ref} />);
108+
109+
act(() => ref.current?.run('SELECT 42;'));
110+
expect(onRun).toHaveBeenCalledWith('SELECT 42;');
111+
112+
fireEvent.click(screen.getByRole('button', { name: 'History' }));
113+
expect(await screen.findByText('SELECT 42;', { selector: 'span' })).toBeInTheDocument();
114+
});
102115
});

frontend/src/components/pages/sql/sql-editor.tsx

Lines changed: 13 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,20 @@ import {
3838
useSyncExternalStore,
3939
} from 'react';
4040
import { isMacOS } from 'utils/platform';
41-
import { z } from 'zod';
4241

42+
import { type HistoryEntry, loadHistory, pushHistory, saveHistory } from './sql-history';
4343
import type { Catalog, TableRef } from './sql-types';
4444

4545
// Lets the workspace open a query in a new editor tab.
4646
export type SqlEditorHandle = {
4747
/** Open `sql` in a new tab named `name` (or "Query N") and focus it. */
4848
setQuery: (sql: string, name?: string) => void;
49+
/**
50+
* Execute `sql` through the editor's run path, so it is recorded in the
51+
* query history exactly like a user-initiated run. Used by the workspace's
52+
* auto-run entry from the landing page.
53+
*/
54+
run: (sql: string) => void;
4955
};
5056

5157
export type SqlEditorProps = {
@@ -57,41 +63,6 @@ export type SqlEditorProps = {
5763
initialQuery?: string;
5864
};
5965

60-
const HISTORY_KEY = 'rp_sql_history_v1';
61-
62-
const HistoryEntrySchema = z.object({ sql: z.string(), at: z.number() });
63-
64-
type HistoryEntry = z.infer<typeof HistoryEntrySchema>;
65-
66-
function loadHistory(): HistoryEntry[] {
67-
if (typeof localStorage === 'undefined') {
68-
return [];
69-
}
70-
try {
71-
const raw: unknown = JSON.parse(localStorage.getItem(HISTORY_KEY) ?? '[]');
72-
if (!Array.isArray(raw)) {
73-
return [];
74-
}
75-
return raw.flatMap((entry) => {
76-
const parsed = HistoryEntrySchema.safeParse(entry);
77-
return parsed.success ? [parsed.data] : [];
78-
});
79-
} catch {
80-
return [];
81-
}
82-
}
83-
84-
function saveHistory(list: HistoryEntry[]): void {
85-
if (typeof localStorage === 'undefined') {
86-
return;
87-
}
88-
try {
89-
localStorage.setItem(HISTORY_KEY, JSON.stringify(list.slice(0, 40)));
90-
} catch {
91-
// best-effort; ignore quota/serialization failures
92-
}
93-
}
94-
9566
type Tab = { id: number; name: string; sql: string };
9667

9768
const DEFAULT_QUERY =
@@ -342,6 +313,7 @@ export const SqlEditor = forwardRef<SqlEditorHandle, SqlEditorProps>(
342313
// Latest run callback, bound into the Cmd/Ctrl+Enter keymap (built once per
343314
// catalog/theme change, not per render).
344315
const runRef = useRef<() => void>(() => undefined);
316+
const runTextRef = useRef<(text: string) => void>(() => undefined);
345317

346318
const active = tabs.find((t) => t.id === activeId) ?? tabs[0];
347319

@@ -354,6 +326,7 @@ export const SqlEditor = forwardRef<SqlEditorHandle, SqlEditorProps>(
354326
setActiveId(id);
355327
requestAnimationFrame(() => editorRef.current?.view?.focus());
356328
},
329+
run: (sql: string) => runTextRef.current(sql),
357330
}),
358331
[nextTabId]
359332
);
@@ -367,12 +340,14 @@ export const SqlEditor = forwardRef<SqlEditorHandle, SqlEditorProps>(
367340
if (!trimmed) {
368341
return;
369342
}
370-
const entry: HistoryEntry = { sql: trimmed, at: Date.now() };
371-
const nh = [entry, ...history.filter((h) => h.sql !== entry.sql)].slice(0, 40);
343+
const nh = pushHistory(history, trimmed, Date.now());
372344
setHistory(nh);
373345
saveHistory(nh);
374346
runQuery(trimmed);
375347
};
348+
// Latest runText, so the imperative handle (built once) records history
349+
// against the current state instead of a stale closure.
350+
runTextRef.current = runText;
376351

377352
// Run the current selection if any, else the whole tab.
378353
const doRun = () => {
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* Copyright 2026 Redpanda Data, Inc.
3+
*
4+
* Use of this software is governed by the Business Source License
5+
* included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
6+
*
7+
* As of the Change Date specified in that file, in accordance with
8+
* the Business Source License, use of this software will be governed
9+
* by the Apache License, Version 2.0
10+
*/
11+
12+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
13+
14+
import { type HistoryEntry, loadHistory, MAX_HISTORY, pushHistory, saveHistory } from './sql-history';
15+
16+
const HISTORY_KEY = 'rp_sql_history_v1';
17+
18+
function createMemoryStorage(): Storage {
19+
const values = new Map<string, string>();
20+
return {
21+
get length() {
22+
return values.size;
23+
},
24+
clear: () => values.clear(),
25+
getItem: (key: string) => values.get(key) ?? null,
26+
key: (index: number) => [...values.keys()][index] ?? null,
27+
removeItem: (key: string) => values.delete(key),
28+
setItem: (key: string, value: string) => values.set(key, value),
29+
};
30+
}
31+
32+
beforeEach(() => {
33+
vi.stubGlobal('localStorage', createMemoryStorage());
34+
});
35+
36+
afterEach(() => {
37+
vi.unstubAllGlobals();
38+
});
39+
40+
describe('pushHistory', () => {
41+
test('prepends the newest entry', () => {
42+
const list = pushHistory([{ sql: 'SELECT 1', at: 1 }], 'SELECT 2', 2);
43+
expect(list.map((entry) => entry.sql)).toEqual(['SELECT 2', 'SELECT 1']);
44+
});
45+
46+
test('dedupes an identical earlier run instead of listing it twice', () => {
47+
const list = pushHistory(
48+
[
49+
{ sql: 'SELECT 1', at: 1 },
50+
{ sql: 'SELECT 2', at: 2 },
51+
],
52+
'SELECT 1',
53+
3
54+
);
55+
expect(list).toEqual([
56+
{ sql: 'SELECT 1', at: 3 },
57+
{ sql: 'SELECT 2', at: 2 },
58+
]);
59+
});
60+
61+
test('caps the list length', () => {
62+
let list: HistoryEntry[] = [];
63+
for (let i = 0; i < MAX_HISTORY + 10; i += 1) {
64+
list = pushHistory(list, `SELECT ${i}`, i);
65+
}
66+
expect(list).toHaveLength(MAX_HISTORY);
67+
expect(list[0].sql).toBe(`SELECT ${MAX_HISTORY + 9}`);
68+
});
69+
});
70+
71+
describe('loadHistory / saveHistory', () => {
72+
test('round-trips entries through localStorage', () => {
73+
saveHistory([{ sql: 'SELECT 1', at: 42 }]);
74+
expect(loadHistory()).toEqual([{ sql: 'SELECT 1', at: 42 }]);
75+
});
76+
77+
test('drops malformed entries but keeps valid ones', () => {
78+
localStorage.setItem(HISTORY_KEY, JSON.stringify([{ sql: 'SELECT 1', at: 1 }, { sql: 7 }, 'nope', null]));
79+
expect(loadHistory()).toEqual([{ sql: 'SELECT 1', at: 1 }]);
80+
});
81+
82+
test('returns empty for a non-array value', () => {
83+
localStorage.setItem(HISTORY_KEY, JSON.stringify({ not: 'a list' }));
84+
expect(loadHistory()).toEqual([]);
85+
});
86+
87+
test('returns empty for invalid JSON', () => {
88+
localStorage.setItem(HISTORY_KEY, '{broken');
89+
expect(loadHistory()).toEqual([]);
90+
});
91+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* Copyright 2026 Redpanda Data, Inc.
3+
*
4+
* Use of this software is governed by the Business Source License
5+
* included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
6+
*
7+
* As of the Change Date specified in that file, in accordance with
8+
* the Business Source License, use of this software will be governed
9+
* by the Apache License, Version 2.0
10+
*/
11+
12+
import { config, isEmbedded } from 'config';
13+
import { z } from 'zod';
14+
15+
// The single source of truth for the SQL query-run history persisted in
16+
// localStorage. Written by the editor on every run and read back by both the
17+
// editor's History popover and the landing page's "Recent queries" card —
18+
// shared here so the key and entry shape can't drift between the two.
19+
20+
export const HistoryEntrySchema = z.object({ sql: z.string(), at: z.number() });
21+
22+
export type HistoryEntry = z.infer<typeof HistoryEntrySchema>;
23+
24+
export const MAX_HISTORY = 40;
25+
26+
// Tolerates individually malformed entries: keeps the valid ones, drops the rest.
27+
const StoredHistorySchema = z.array(z.unknown()).transform((list) =>
28+
list.flatMap((entry) => {
29+
const parsed = HistoryEntrySchema.safeParse(entry);
30+
return parsed.success ? [parsed.data] : [];
31+
})
32+
);
33+
34+
const HISTORY_KEY_BASE = 'rp_sql_history_v1';
35+
36+
// localStorage is origin-scoped, and embedded Console serves every cluster
37+
// from the same Cloud UI origin — an unscoped key would surface cluster A's
38+
// queries as one-click runs on cluster B. Standalone serves one cluster per
39+
// origin, so the bare key suffices there.
40+
function historyKey(): string {
41+
return isEmbedded() ? `${HISTORY_KEY_BASE}:${config.clusterId ?? 'default'}` : HISTORY_KEY_BASE;
42+
}
43+
44+
// Kept defensive so a malformed or truncated stored value never throws.
45+
export function loadHistory(): HistoryEntry[] {
46+
if (typeof localStorage === 'undefined') {
47+
return [];
48+
}
49+
try {
50+
return StoredHistorySchema.catch([]).parse(JSON.parse(localStorage.getItem(historyKey()) ?? '[]'));
51+
} catch {
52+
return [];
53+
}
54+
}
55+
56+
export function saveHistory(list: HistoryEntry[]): void {
57+
if (typeof localStorage === 'undefined') {
58+
return;
59+
}
60+
try {
61+
localStorage.setItem(historyKey(), JSON.stringify(list.slice(0, MAX_HISTORY)));
62+
} catch {
63+
// best-effort; ignore quota/serialization failures
64+
}
65+
}
66+
67+
/** Prepend `sql` to `list`, deduping an identical earlier run and capping the length. */
68+
export function pushHistory(list: HistoryEntry[], sql: string, at: number): HistoryEntry[] {
69+
return [{ sql, at }, ...list.filter((entry) => entry.sql !== sql)].slice(0, MAX_HISTORY);
70+
}

frontend/src/components/pages/sql/sql-landing.test.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,19 +58,22 @@ function renderLanding(overrides: Partial<SqlLandingProps> = {}) {
5858
const onRunQuery = vi.fn();
5959
const onOpenEditor = vi.fn();
6060
const onAddTopic = vi.fn();
61+
const onRetry = vi.fn();
6162
render(
6263
<SqlLanding
6364
catalogs={catalogs}
6465
hasTables={true}
66+
isError={false}
6567
isLoading={false}
6668
onAddTopic={onAddTopic}
6769
onOpenEditor={onOpenEditor}
70+
onRetry={onRetry}
6871
onRunQuery={onRunQuery}
6972
sqlRole="admin"
7073
{...overrides}
7174
/>
7275
);
73-
return { onAddTopic, onOpenEditor, onRunQuery };
76+
return { onAddTopic, onOpenEditor, onRetry, onRunQuery };
7477
}
7578

7679
afterEach(() => {
@@ -106,6 +109,24 @@ describe('SqlLanding populated overview', () => {
106109
});
107110
});
108111

112+
describe('SqlLanding loading and error states', () => {
113+
test('a load in flight shows the loading row, not the empty-state onboarding', () => {
114+
renderLanding({ isLoading: true, catalogs: [], hasTables: false });
115+
expect(screen.getByText('Loading catalogs…')).toBeInTheDocument();
116+
expect(screen.queryByText('No tables yet')).toBeNull();
117+
expect(screen.queryByText(/Connected/)).toBeNull();
118+
});
119+
120+
test('a failed catalog load shows the error state with retry, not the empty state', async () => {
121+
const { onRetry } = renderLanding({ isError: true, catalogs: [], hasTables: false });
122+
expect(screen.getByText("Couldn't load the SQL catalog")).toBeInTheDocument();
123+
expect(screen.queryByText('No tables yet')).toBeNull();
124+
expect(screen.queryByText(/Connected/)).toBeNull();
125+
await userEvent.click(screen.getByRole('button', { name: /Retry/ }));
126+
expect(onRetry).toHaveBeenCalledOnce();
127+
});
128+
});
129+
109130
describe('SqlLanding onboarding (empty catalog)', () => {
110131
test('admin sees the add-topic CTA', async () => {
111132
const { onAddTopic } = renderLanding({ hasTables: false, catalogs: [] });

0 commit comments

Comments
 (0)