From 79c4c4ad4b41e597504fc34fffd92926400c7818 Mon Sep 17 00:00:00 2001 From: huyplb Date: Sun, 25 Oct 2026 10:59:54 -0600 Subject: [PATCH 1/4] wip: Data Peek grid fixes and e2e (Escape, limit box, page after a failed fetch) Co-Authored-By: Claude Opus 5.5 --- .../tests/sql-editor-data-peek-grid.test.ts | 426 ++++++++++++++++++ .../sql-editor/components/DataGrid.tsx | 6 +- .../sql-editor/components/DataPeekPanel.tsx | 13 +- .../sql-editor/state/useSqlEditorStore.ts | 4 + 4 files changed, 447 insertions(+), 2 deletions(-) create mode 100644 apps/e2e/src/tests/sql-editor-data-peek-grid.test.ts diff --git a/apps/e2e/src/tests/sql-editor-data-peek-grid.test.ts b/apps/e2e/src/tests/sql-editor-data-peek-grid.test.ts new file mode 100644 index 000000000..d8e9575e5 --- /dev/null +++ b/apps/e2e/src/tests/sql-editor-data-peek-grid.test.ts @@ -0,0 +1,426 @@ +/** + * Data Peek grid, end to end against SQLite. + * + * What a reader does with a peek: page through a table, narrow it with WHERE, + * sort it, follow a foreign key to the parent row and back to the children + * that point at a row, and close what they opened. Each test asserts on the + * rows the panel shows, read out of the grid itself, so a filter that applied + * to the wrong panel or a page that never loaded cannot pass. + * + * Fixture: 3 regions, 12 customers (region_id and referrer_id are foreign + * keys), 30 orders (customer_id). Every value is a function of the id, so each + * expectation below can be worked out from the id alone. + * + * Requires the web app + API (`npm run dev`) and a `sqlite3` CLI on PATH. + */ +import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest'; +import { execFileSync, execSync } from 'node:child_process'; +import { mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Page } from 'playwright'; +import { buildDriver, quitDriver } from '../helpers/driver.js'; +import { byTestId } from '../helpers/test-ids.js'; +import { AppPage } from '../pages/AppPage.js'; +import { SqlEditorPage } from '../pages/SqlEditorPage.js'; + +const RUN = Date.now().toString(36); +// Per-run directory: the backend pools SQLite handles by file path, so reusing +// one across runs writes through a handle pointing at a deleted inode. +const DIR = `/tmp/foxschema-e2e-peek-grid-${RUN}`; +const DB = join(DIR, 'peek.db'); +const NAME = `E2E Peek Grid ${RUN}`; + +function hasSqlite3(): boolean { + try { + execSync('which sqlite3', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +const FIXTURE = ` +PRAGMA foreign_keys = ON; +CREATE TABLE region (id INTEGER PRIMARY KEY, name TEXT NOT NULL); +CREATE TABLE customer ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + region_id INTEGER REFERENCES region(id), + referrer_id INTEGER REFERENCES customer(id), + tier TEXT NOT NULL +); +CREATE TABLE orders ( + id INTEGER PRIMARY KEY, + customer_id INTEGER NOT NULL REFERENCES customer(id), + total INTEGER NOT NULL, + status TEXT NOT NULL +); +INSERT INTO region (id, name) VALUES (1, 'North'), (2, 'South'), (3, 'East'); +WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 12) +INSERT INTO customer (id, name, region_id, referrer_id, tier) +SELECT i, printf('c%02d', i), ((i - 1) % 3) + 1, CASE WHEN i = 1 THEN NULL ELSE 1 END, + CASE WHEN i % 4 = 0 THEN 'gold' ELSE 'basic' END +FROM n; +WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 30) +INSERT INTO orders (id, customer_id, total, status) +SELECT i, ((i - 1) % 12) + 1, i * 10, CASE WHEN i % 3 = 0 THEN 'paid' ELSE 'open' END +FROM n; +`; + +describe.skipIf(!hasSqlite3())('SQL Editor · Data Peek grid', () => { + let driver: Page; + let app: AppPage; + let sql: SqlEditorPage; + + /** Ids of the open panels, in screen order. */ + const panelIds = (): Promise => + driver.evaluate(() => + [...document.querySelectorAll('[data-testid^="data-peek-grid-"]')].map((el) => + (el.getAttribute('data-testid') ?? '').slice('data-peek-grid-'.length) + ) + ); + + /** The rows a panel shows, as cell text, without the row-number column. */ + const rowsOf = (id: string): Promise => + driver.evaluate((pid) => { + const grid = document.querySelector( + `[data-testid="data-peek-grid-${pid}"] [data-testid="sql-data-grid"]` + ); + if (!grid) return []; + return [...grid.querySelectorAll('tbody tr')] + .filter((tr) => tr.querySelector('[data-testid="sql-row-num"]')) + .map((tr) => + [...tr.querySelectorAll('td')].slice(1).map((td) => (td.textContent ?? '').trim()) + ); + }, id); + + /** The first column of every row — the id, for each table here. */ + const idsOf = async (id: string): Promise => (await rowsOf(id)).map((r) => r[0] ?? ''); + + /** The row numbers the grid prints in its # column. */ + const rowNumbersOf = (id: string): Promise => + driver.evaluate( + (pid) => + [ + ...document.querySelectorAll( + `[data-testid="data-peek-grid-${pid}"] [data-testid="sql-row-num"]` + ), + ].map((td) => (td.textContent ?? '').trim()), + id + ); + + /** The panel's own text — title, footer, error. */ + const textOf = (id: string): Promise => + driver.locator(byTestId(`data-peek-grid-${id}`)).innerText(); + + /** "page N" from the grid footer, or null when no grid is showing. */ + const pageOf = async (id: string): Promise => { + const m = /· page (\d+)/.exec(await textOf(id)); + return m ? Number(m[1]) : null; + }; + + /** Type into a filter box and press Enter, which applies at once. */ + async function filter(id: string, box: 'where' | 'order' | 'limit', value: string): Promise { + const input = driver.locator(byTestId(`data-peek-${box}-${id}`)); + await input.fill(value); + await input.press('Enter'); + } + + /** Open a fresh peek on `table` and return its root panel id. */ + async function peek(table: string): Promise { + if (await driver.locator(byTestId('data-peek')).count()) { + await driver.locator(byTestId('data-peek-close')).click(); + await driver.locator(byTestId('data-peek')).waitFor({ state: 'detached' }); + } + await sql.openDataPeek(table); + const [root] = await panelIds(); + expect(root, `no panel opened for ${table}`).toBeTruthy(); + await expect.poll(() => rowsOf(root!), { timeout: 20_000 }).not.toHaveLength(0); + return root!; + } + + beforeAll(async () => { + rmSync(DIR, { recursive: true, force: true }); + mkdirSync(DIR, { recursive: true }); + execFileSync('sqlite3', [DB], { input: FIXTURE }); + + driver = await buildDriver(); + app = new AppPage(driver); + sql = new SqlEditorPage(driver); + + await app.open(); + await sql.resetPersistedEditorState(); + await driver.reload(); + await driver.waitForSelector('[data-testid="toolbar"]'); + await sql.addSqliteCredential(NAME, DB); + await sql.openView(); + await sql.checkConnection(NAME); + }, 180_000); + + afterAll(async () => { + if (driver) await quitDriver(driver); + rmSync(DIR, { recursive: true, force: true }); + }); + + beforeEach(async () => { + await driver.unrouteAll({ behavior: 'ignoreErrors' }); + }); + + it('pages through a table at the limit typed, numbering rows across pages', async () => { + const root = await peek('orders'); + await filter(root, 'limit', '10'); + await expect.poll(() => idsOf(root)).toEqual(['1', '2', '3', '4', '5', '6', '7', '8', '9', '10']); + expect(await pageOf(root)).toBe(1); + + const panel = driver.locator(byTestId(`data-peek-grid-${root}`)); + await panel.locator(byTestId('sql-page-next')).click(); + await expect.poll(() => idsOf(root)).toEqual(['11', '12', '13', '14', '15', '16', '17', '18', '19', '20']); + expect(await pageOf(root)).toBe(2); + // The # column continues from page one instead of restarting at 1. + expect((await rowNumbersOf(root))[0]).toBe('11'); + + await panel.locator(byTestId('sql-page-next')).click(); + await expect.poll(() => idsOf(root).then((ids) => ids[0])).toBe('21'); + expect((await idsOf(root))[9]).toBe('30'); + + await panel.locator(byTestId('sql-page-prev')).click(); + await expect.poll(() => (idsOf(root)).then((ids) => ids[0])).toBe('11'); + expect(await pageOf(root)).toBe(2); + }, 90_000); + + it('filters with WHERE, and the clear button brings every row back', async () => { + const root = await peek('orders'); + await filter(root, 'where', "status = 'paid'"); + // Every third order is paid. + await expect + .poll(() => idsOf(root)) + .toEqual(['3', '6', '9', '12', '15', '18', '21', '24', '27', '30']); + + await driver.locator(byTestId(`data-peek-where-clear-${root}`)).click(); + await expect.poll(() => rowsOf(root).then((r) => r.length), { timeout: 15_000 }).toBeGreaterThan(10); + expect(await driver.locator(byTestId(`data-peek-where-${root}`)).inputValue()).toBe(''); + }, 60_000); + + it('a filter starts again from page one', async () => { + const root = await peek('orders'); + await filter(root, 'limit', '5'); + await expect.poll(() => idsOf(root)).toHaveLength(5); + await driver.locator(byTestId(`data-peek-grid-${root}`)).locator(byTestId('sql-page-next')).click(); + await expect.poll(() => pageOf(root)).toBe(2); + + await filter(root, 'where', 'total >= 200'); + // Orders 20..30 have total >= 200; page one of five of them. + await expect.poll(() => idsOf(root)).toEqual(['20', '21', '22', '23', '24']); + expect(await pageOf(root)).toBe(1); + }, 60_000); + + it('sorts with ORDER BY', async () => { + const root = await peek('orders'); + await filter(root, 'order', 'total DESC'); + await expect.poll(() => idsOf(root).then((ids) => ids.slice(0, 3))).toEqual(['30', '29', '28']); + }, 60_000); + + it('refuses a WHERE that carries a second statement, and recovers when fixed', async () => { + const root = await peek('orders'); + await filter(root, 'where', 'id = 1; DELETE FROM orders'); + await expect.poll(() => textOf(root)).toMatch(/single predicate/i); + // Nothing ran: the table still has all thirty orders. + expect( + execFileSync('sqlite3', [DB, 'SELECT COUNT(*) FROM orders'], { encoding: 'utf8' }).trim() + ).toBe('30'); + + await filter(root, 'where', 'id = 7'); + await expect.poll(() => idsOf(root)).toEqual(['7']); + expect(await textOf(root)).not.toMatch(/single predicate/i); + }, 60_000); + + it('shows the database error for a WHERE it cannot run, and recovers when fixed', async () => { + const root = await peek('orders'); + await filter(root, 'where', 'no_such_column = 1'); + await expect.poll(() => textOf(root)).toMatch(/no_such_column/i); + await filter(root, 'where', 'customer_id = 12'); + // Customer 12 owns orders 12 and 24. + await expect.poll(() => idsOf(root)).toEqual(['12', '24']); + }, 60_000); + + it('keeps the Limit box showing the limit in force', async () => { + const root = await peek('orders'); + const limit = driver.locator(byTestId(`data-peek-limit-${root}`)); + + // Out of range is clamped, and the box says so instead of keeping the typo. + await filter(root, 'limit', '99999'); + await expect.poll(() => limit.inputValue()).toBe('5000'); + await filter(root, 'limit', '99999'); + await expect.poll(() => limit.inputValue()).toBe('5000'); + + await filter(root, 'limit', '0'); + await expect.poll(() => idsOf(root)).toEqual(['1']); + await expect.poll(() => limit.inputValue()).toBe('1'); + + // An emptied box goes back to the limit still applied when it loses focus. + await limit.fill(''); + await limit.blur(); + await expect.poll(() => limit.inputValue()).toBe('1'); + expect(await idsOf(root)).toEqual(['1']); + }, 60_000); + + it('follows a foreign key into a keyed panel, replaces it per column, and stacks other columns', async () => { + const root = await peek('customer'); + const columns = await driver.evaluate( + (pid) => + [ + ...document.querySelectorAll( + `[data-testid="data-peek-grid-${pid}"] [data-testid="sql-col-header"]` + ), + ] + // A header reads "▲region_idnum": sort arrow, name, type badge. + .map((th) => (th.textContent ?? '').trim().toLowerCase().replace(/^[^a-z_]+/, '')), + root + ); + const regionCol = columns.findIndex((c) => c.startsWith('region_id')); + const referrerCol = columns.findIndex((c) => c.startsWith('referrer_id')); + expect(regionCol, `columns: ${columns.join(', ')}`).toBeGreaterThanOrEqual(0); + expect(referrerCol).toBeGreaterThanOrEqual(0); + + const rootPanel = driver.locator(byTestId(`data-peek-grid-${root}`)); + // Row 0 is customer 1 → region 1. + await rootPanel.locator(byTestId(`grid-fk-link-0-${regionCol}`)).click(); + await expect.poll(() => panelIds()).toHaveLength(2); + const [, region1] = await panelIds(); + await expect.poll(() => rowsOf(region1!)).toEqual([['1', 'North']]); + expect(await driver.locator(byTestId(`data-peek-base-filter-${region1}`)).innerText()).toMatch( + /id = 1/ + ); + + // Row 1 is customer 2 → region 2, replacing the region panel, not adding one. + await rootPanel.locator(byTestId(`grid-fk-link-1-${regionCol}`)).click(); + await expect.poll(async () => (await panelIds()).length).toBe(2); + const [, region2] = await panelIds(); + expect(region2).not.toBe(region1); + await expect.poll(() => rowsOf(region2!)).toEqual([['2', 'South']]); + + // A different FK column of the same row opens a sibling panel. + await rootPanel.locator(byTestId(`grid-fk-link-1-${referrerCol}`)).click(); + await expect.poll(async () => (await panelIds()).length).toBe(3); + const referrer = (await panelIds())[2]!; + await expect.poll(() => idsOf(referrer)).toEqual(['1']); + + // All rows drops the bound key and fetches the whole parent table. + await driver.locator(byTestId(`data-peek-clear-key-${region2}`)).click(); + await expect.poll(() => idsOf(region2!)).toEqual(['1', '2', '3']); + expect(await driver.locator(byTestId(`data-peek-base-filter-${region2}`)).count()).toBe(0); + }, 90_000); + + it('lists the children that point at the selected row', async () => { + const root = await peek('customer'); + const chip = driver.locator('[data-testid^="data-peek-refby-orders-"]').first(); + await chip.waitFor({ state: 'visible', timeout: 15_000 }); + expect(await chip.isDisabled(), 'chip should wait for a row to be chosen').toBe(true); + + // Customer 5 (row 4) owns orders 5, 17 and 29. + await driver + .locator(byTestId(`data-peek-grid-${root}`)) + .locator('[data-testid="sql-row-4"] td') + .nth(2) + .click(); + await expect.poll(() => chip.isDisabled()).toBe(false); + await chip.click(); + await expect.poll(async () => (await panelIds()).length).toBe(2); + const child = (await panelIds())[1]!; + await expect.poll(() => idsOf(child)).toEqual(['5', '17', '29']); + }, 60_000); + + it('closing a panel closes the panels opened from it; closing the root closes the peek', async () => { + const root = await peek('orders'); + const rootPanel = driver.locator(byTestId(`data-peek-grid-${root}`)); + // orders.customer_id is the second column. + await rootPanel.locator(byTestId('grid-fk-link-0-1')).click(); + await expect.poll(async () => (await panelIds()).length).toBe(2); + const customer = (await panelIds())[1]!; + await expect.poll(() => idsOf(customer)).toEqual(['1']); + + // customer 1's region_id is the third column. + await driver.locator(byTestId(`data-peek-grid-${customer}`)).locator(byTestId('grid-fk-link-0-2')).click(); + await expect.poll(async () => (await panelIds()).length).toBe(3); + + await driver.locator(byTestId(`data-peek-close-panel-${customer}`)).click(); + await expect.poll(() => panelIds()).toEqual([root]); + + await driver.locator(byTestId('data-peek-close')).click(); + await driver.locator(byTestId('data-peek')).waitFor({ state: 'detached', timeout: 10_000 }); + }, 60_000); + + it('Escape clears a cell selection first, and only then closes the peek', async () => { + const root = await peek('orders'); + const rootPanel = driver.locator(byTestId(`data-peek-grid-${root}`)); + await rootPanel.locator('[data-testid="sql-row-2"] td').nth(3).click(); + await expect.poll(() => rootPanel.locator('td[data-selected="true"]').count()).toBeGreaterThan(0); + + await driver.keyboard.press('Escape'); + await expect.poll(() => rootPanel.locator('td[data-selected="true"]').count()).toBe(0); + expect( + await driver.locator(byTestId('data-peek')).count(), + 'Escape meant for the cell selection closed the whole peek' + ).toBe(1); + + await driver.keyboard.press('Escape'); + await driver.locator(byTestId('data-peek')).waitFor({ state: 'detached', timeout: 10_000 }); + }, 60_000); + + it('Escape in the save-as-variable dialog closes the dialog, not the peek', async () => { + const root = await peek('orders'); + const rootPanel = driver.locator(byTestId(`data-peek-grid-${root}`)); + await rootPanel.locator('[data-testid="sql-row-0"] td').nth(3).click({ button: 'right' }); + await driver.locator(byTestId('sql-grid-save-cell-as')).click(); + const name = driver.locator(byTestId('sql-grid-save-name')); + await name.waitFor({ state: 'visible' }); + await name.press('Escape'); + + await driver.locator(byTestId('sql-variable-save-dialog')).waitFor({ state: 'detached' }); + expect( + await driver.locator(byTestId('data-peek')).count(), + 'Escape meant for the dialog closed the whole peek' + ).toBe(1); + }, 60_000); + + it('after a failed page fetch, the page number matches the rows still shown', async () => { + const root = await peek('orders'); + await filter(root, 'limit', '10'); + await expect.poll(() => idsOf(root)).toHaveLength(10); + expect(await pageOf(root)).toBe(1); + + // The next execute fails the way a dropped connection or a 429 does. + await driver.route('**/api/sql/execute', (route) => route.abort('failed'), { times: 1 }); + await driver.locator(byTestId(`data-peek-grid-${root}`)).locator(byTestId('sql-page-next')).click(); + await expect.poll(() => textOf(root)).toMatch(/fail|error/i); + + const ids = await idsOf(root); + if (ids.length > 0) { + // Rows 1-10 are still on screen, so the panel must still call them page 1, + // numbered from 1 — not page 2 numbered 11-20. + expect(ids[0]).toBe('1'); + expect(await pageOf(root)).toBe(1); + expect((await rowNumbersOf(root))[0]).toBe('1'); + } + + // Next works once the connection is back. + await driver.locator(byTestId(`data-peek-grid-${root}`)).locator(byTestId('sql-page-next')).click(); + await expect.poll(() => idsOf(root).then((r) => r[0])).toBe('11'); + expect(await pageOf(root)).toBe(2); + }, 60_000); + + it('keeps the filter across a visit to Insight', async () => { + const root = await peek('orders'); + await filter(root, 'where', "status = 'paid' AND total > 200"); + await expect.poll(() => idsOf(root)).toEqual(['21', '24', '27', '30']); + + await driver.locator(byTestId(`data-peek-tab-insight-${root}`)).click(); + await driver.locator(byTestId('data-peek-insight')).waitFor({ state: 'visible', timeout: 15_000 }); + await driver.locator(byTestId(`data-peek-tab-rows-${root}`)).click(); + + expect(await driver.locator(byTestId(`data-peek-where-${root}`)).inputValue()).toBe( + "status = 'paid' AND total > 200" + ); + await expect.poll(() => idsOf(root)).toEqual(['21', '24', '27', '30']); + }, 60_000); +}); diff --git a/apps/web/src/frontend/features/sql-editor/components/DataGrid.tsx b/apps/web/src/frontend/features/sql-editor/components/DataGrid.tsx index 5761b9035..4d2f4f974 100644 --- a/apps/web/src/frontend/features/sql-editor/components/DataGrid.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/DataGrid.tsx @@ -1971,7 +1971,11 @@ export const DataGrid: React.FC<{ onChange={(e) => setSaveName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') commitSave(); - if (e.key === 'Escape') setSavePrompt(null); + if (e.key === 'Escape') { + // Marked handled, so an enclosing Data Peek does not close too. + e.preventDefault(); + setSavePrompt(null); + } }} placeholder="variable_name" className="w-full bg-slate-950 border border-slate-700 rounded px-2 py-1.5 text-xs text-slate-100 outline-none accent-focus mb-2" diff --git a/apps/web/src/frontend/features/sql-editor/components/DataPeekPanel.tsx b/apps/web/src/frontend/features/sql-editor/components/DataPeekPanel.tsx index ad00e7d50..b1d295007 100644 --- a/apps/web/src/frontend/features/sql-editor/components/DataPeekPanel.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/DataPeekPanel.tsx @@ -24,6 +24,7 @@ import { peekBaseFilterLabel, } from '@/shared/lib/tablePreview'; import type { InboundForeignKey } from '@/shared/lib/tablePreview'; +import { clampDataPeekLimit } from '@/features/sql-editor/state/sqlEditorDataPeek'; import { DataGrid } from './DataGrid'; import { usePeekGridCrud } from './usePeekGridCrud'; import { PeekInsight } from './PeekInsight'; @@ -70,7 +71,13 @@ const PeekFilterBar: React.FC<{ const draftOrderBy = orderByRef.current; const draftLimit = limitRef.current; const parsed = Number.parseInt(draftLimit, 10); - const nextLimit = Number.isFinite(parsed) ? parsed : entry.limit; + // Clamped here as the store does, and written back, so the box always shows + // the limit in force: an emptied box or a typo above the cap used to stay. + const nextLimit = Number.isFinite(parsed) ? clampDataPeekLimit(parsed) : entry.limit; + if (draftLimit !== String(nextLimit)) { + setLimit(String(nextLimit)); + limitRef.current = String(nextLimit); + } // Skip no-op applies (e.g. blur after an already-committed clear). if ( draftWhere === entry.whereClause && @@ -672,6 +679,10 @@ export const DataPeekPanel: React.FC = () => { if (e.key !== 'Escape') return; // Row editor / write confirm own Escape — do not close the whole peek. if (childOverlayOpen) return; + // Something inside already used this Escape: the grid clearing a cell + // selection, or its save-as-variable dialog closing. Closing the peek + // too threw away every panel the reader had drilled open. + if (e.defaultPrevented) return; closeDataPeek(); }; window.addEventListener('keydown', onKey); diff --git a/apps/web/src/frontend/features/sql-editor/state/useSqlEditorStore.ts b/apps/web/src/frontend/features/sql-editor/state/useSqlEditorStore.ts index d441081af..07a7dd026 100644 --- a/apps/web/src/frontend/features/sql-editor/state/useSqlEditorStore.ts +++ b/apps/web/src/frontend/features/sql-editor/state/useSqlEditorStore.ts @@ -2221,9 +2221,13 @@ export const useSqlEditorStore = create()( : { status: 'error', error: result.error, result } ); } catch (error: unknown) { + // The rows of the last good page stay on screen, so the page number + // goes back to theirs. Left at the page that failed, the panel called + // page one's rows page two and numbered them from 11. patchIfCurrent({ status: 'error', error: error instanceof Error ? error.message : String(error), + ...(entry.result?.ok ? { pageIndex: entry.resultPageIndex ?? 0 } : {}), }); } }, From 5a10c486ff04b5a6e0f89a79263d5de7c5536d35 Mon Sep 17 00:00:00 2001 From: huyplb Date: Sun, 25 Oct 2026 11:30:02 -0600 Subject: [PATCH 2/4] fix(data-peek): the panel's height goes to the grid, so filter rows never hide the rows Main's multi-row filter (WHERE and ORDER BY rows) grew the controls above the grid while the panel kept a fixed 360 px: with two filter rows the grid showed its header and none of its rows. The height is now the body's (grid or Insight); the controls take what they need. The e2e follows the new filter: the refusal text, and one WHERE row per AND condition. Co-Authored-By: Claude Opus 5.5 --- .../tests/sql-editor-data-peek-grid.test.ts | 10 ++--- .../components/DataPeekPanel.test.tsx | 10 +++++ .../sql-editor/components/DataPeekPanel.tsx | 40 +++++++++++-------- .../sql-editor/state/sqlEditorDataPeek.ts | 2 +- 4 files changed, 40 insertions(+), 22 deletions(-) diff --git a/apps/e2e/src/tests/sql-editor-data-peek-grid.test.ts b/apps/e2e/src/tests/sql-editor-data-peek-grid.test.ts index d8e9575e5..a21ccf6fe 100644 --- a/apps/e2e/src/tests/sql-editor-data-peek-grid.test.ts +++ b/apps/e2e/src/tests/sql-editor-data-peek-grid.test.ts @@ -223,7 +223,7 @@ describe.skipIf(!hasSqlite3())('SQL Editor · Data Peek grid', () => { it('refuses a WHERE that carries a second statement, and recovers when fixed', async () => { const root = await peek('orders'); await filter(root, 'where', 'id = 1; DELETE FROM orders'); - await expect.poll(() => textOf(root)).toMatch(/single predicate/i); + await expect.poll(() => textOf(root)).toMatch(/cannot contain ; or comments/i); // Nothing ran: the table still has all thirty orders. expect( execFileSync('sqlite3', [DB, 'SELECT COUNT(*) FROM orders'], { encoding: 'utf8' }).trim() @@ -231,7 +231,7 @@ describe.skipIf(!hasSqlite3())('SQL Editor · Data Peek grid', () => { await filter(root, 'where', 'id = 7'); await expect.poll(() => idsOf(root)).toEqual(['7']); - expect(await textOf(root)).not.toMatch(/single predicate/i); + expect(await textOf(root)).not.toMatch(/cannot contain ; or comments/i); }, 60_000); it('shows the database error for a WHERE it cannot run, and recovers when fixed', async () => { @@ -418,9 +418,9 @@ describe.skipIf(!hasSqlite3())('SQL Editor · Data Peek grid', () => { await driver.locator(byTestId('data-peek-insight')).waitFor({ state: 'visible', timeout: 15_000 }); await driver.locator(byTestId(`data-peek-tab-rows-${root}`)).click(); - expect(await driver.locator(byTestId(`data-peek-where-${root}`)).inputValue()).toBe( - "status = 'paid' AND total > 200" - ); + // The filter comes back as typed, one condition per row. + expect(await driver.locator(byTestId(`data-peek-where-${root}`)).inputValue()).toBe("status = 'paid'"); + expect(await driver.locator(byTestId(`data-peek-where-${root}-1`)).inputValue()).toBe('total > 200'); await expect.poll(() => idsOf(root)).toEqual(['21', '24', '27', '30']); }, 60_000); }); diff --git a/apps/web/src/frontend/features/sql-editor/components/DataPeekPanel.test.tsx b/apps/web/src/frontend/features/sql-editor/components/DataPeekPanel.test.tsx index df8bd5951..96671cce3 100644 --- a/apps/web/src/frontend/features/sql-editor/components/DataPeekPanel.test.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/DataPeekPanel.test.tsx @@ -92,6 +92,16 @@ describe('DataPeekPanel Insight tab', () => { expect(peek?.entries[0]?.orderByClause).toBe('created_at DESC, id ASC'); }); }); + + it('gives its height to the grid, so filter rows never squeeze out the rows', () => { + // With the height on the whole panel, two WHERE rows and two ORDER BY rows + // took all of it: the grid showed its header and none of its rows. + render(); + fireEvent.click(screen.getByTestId('data-peek-where-add-e1')); + fireEvent.click(screen.getByTestId('data-peek-order-add-e1')); + expect(screen.getByTestId('data-peek-grid-e1').style.height).toBe(''); + expect(screen.getByTestId('data-peek-body-e1').style.height).toBe('260px'); + }); }); vi.mock('@/features/compare/state/useSyncStore', () => ({ diff --git a/apps/web/src/frontend/features/sql-editor/components/DataPeekPanel.tsx b/apps/web/src/frontend/features/sql-editor/components/DataPeekPanel.tsx index 2142b9f11..cac73528a 100644 --- a/apps/web/src/frontend/features/sql-editor/components/DataPeekPanel.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/DataPeekPanel.tsx @@ -37,11 +37,18 @@ import { SQL_ICON_STROKE } from '@/shared/lib/iconStyle'; import type { TableSchema } from '@/shared/lib/types'; import { useSyncStore } from '@/features/compare'; -const DEFAULT_HEIGHT_ROOT = 360; +/** + * The height of a panel's body — the rows grid or Insight — not of the whole + * panel. The controls above it (WHERE and ORDER BY rows, Limit) take what they + * need: with the height on the whole panel, each condition added took a row + * from the grid, and two filter rows left a grid of headers and no rows. + */ +const DEFAULT_BODY_HEIGHT_ROOT = 260; /** FK drill panels stack full-width; main body scrolls when many are open. */ -const DEFAULT_HEIGHT_DRILL = 320; -const MIN_PANEL_HEIGHT = 200; -const MAX_PANEL_HEIGHT = 900; +const DEFAULT_BODY_HEIGHT_DRILL = 220; +/** The store clamps to the same range (setDataPeekPanelHeight). */ +const MIN_BODY_HEIGHT = 220; +const MAX_BODY_HEIGHT = 900; /** * Test id for one filter row. The first row keeps the original unsuffixed id, @@ -351,8 +358,8 @@ const PeekResizeHandle: React.FC<{ target.setPointerCapture(e.pointerId); const onMove = (ev: PointerEvent) => { const next = Math.min( - MAX_PANEL_HEIGHT, - Math.max(MIN_PANEL_HEIGHT, startH.current + (ev.clientY - startY.current)) + MAX_BODY_HEIGHT, + Math.max(MIN_BODY_HEIGHT, startH.current + (ev.clientY - startY.current)) ); setDataPeekPanelHeight(entryId, next); }; @@ -504,8 +511,8 @@ const PeekGrid: React.FC<{ [links, entry, drillDataPeek] ); - const heightPx = - entry.panelHeightPx ?? (variant === 'drill' ? DEFAULT_HEIGHT_DRILL : DEFAULT_HEIGHT_ROOT); + const bodyHeightPx = + entry.panelHeightPx ?? (variant === 'drill' ? DEFAULT_BODY_HEIGHT_DRILL : DEFAULT_BODY_HEIGHT_ROOT); const afterWrite = useCallback(() => runDataPeekEntry(entry.id), [runDataPeekEntry, entry.id]); const columns = entry.result?.ok ? entry.result.columns : []; @@ -550,7 +557,6 @@ const PeekGrid: React.FC<{ className={`px-2 pb-1 flex flex-col w-full shrink-0 transition-opacity ${ isDragging ? 'opacity-50' : '' } ${isDragOver ? 'ring-2 ring-cyan-500/50 rounded-lg' : ''}`} - style={{ height: heightPx }} data-testid={`data-peek-grid-${entry.id}`} onDragOver={(e) => { e.preventDefault(); @@ -651,11 +657,13 @@ const PeekGrid: React.FC<{ {crud.writeErrorBanner} {tab === 'insight' && ( - +
+ +
)} {tab === 'rows' && entry.status === 'loading' && !entry.result && ( @@ -672,7 +680,7 @@ const PeekGrid: React.FC<{ )} {tab === 'rows' && entry.result?.ok && ( -
+
)} - + {crud.overlays}
diff --git a/apps/web/src/frontend/features/sql-editor/state/sqlEditorDataPeek.ts b/apps/web/src/frontend/features/sql-editor/state/sqlEditorDataPeek.ts index 882e5da43..fad3b959a 100644 --- a/apps/web/src/frontend/features/sql-editor/state/sqlEditorDataPeek.ts +++ b/apps/web/src/frontend/features/sql-editor/state/sqlEditorDataPeek.ts @@ -60,7 +60,7 @@ export interface DataPeekEntry { * Re-clicking the same FK replaces this panel; other FK columns stack. */ drillKey?: string; - /** Optional manual panel height (px); drag handle updates this. */ + /** Optional manual height (px) of the panel's body, the grid or Insight; the drag handle sets it. */ panelHeightPx?: number; /** * Monotonic run id so a slow older execute can't overwrite a newer From 6aa414e807a1f255807f90f82d04b6b713b6b131 Mon Sep 17 00:00:00 2001 From: huyplb Date: Sun, 25 Oct 2026 11:30:29 -0600 Subject: [PATCH 3/4] chore(test-ids): regenerate for the peek body Co-Authored-By: Claude Opus 5.5 --- apps/e2e/src/generated/test-ids.ts | 3 +++ docs/testing/TEST_IDS.md | 1 + 2 files changed, 4 insertions(+) diff --git a/apps/e2e/src/generated/test-ids.ts b/apps/e2e/src/generated/test-ids.ts index 8123afc03..b1558c2d3 100644 --- a/apps/e2e/src/generated/test-ids.ts +++ b/apps/e2e/src/generated/test-ids.ts @@ -1999,6 +1999,8 @@ export const TestIds = { dataPeekBaseFilter: (id: string | number) => `data-peek-base-filter-${id}`, /** button · Clear key filter and fetch all rows */ dataPeekBaseFilterClear: (id: string | number) => `data-peek-base-filter-clear-${id}`, + /** div */ + dataPeekBody: (id: string | number) => `data-peek-body-${id}`, /** button · Clear key filter and fetch all rows */ dataPeekClearKey: (id: string | number) => `data-peek-clear-key-${id}`, /** button · Close data peek */ @@ -4031,6 +4033,7 @@ export type TestId = | `data-peek-apply-${string}` | `data-peek-base-filter-${string}` | `data-peek-base-filter-clear-${string}` + | `data-peek-body-${string}` | `data-peek-clear-key-${string}` | 'data-peek-close' | `data-peek-close-panel-${string}` diff --git a/docs/testing/TEST_IDS.md b/docs/testing/TEST_IDS.md index c3d3fd43d..2b57a407a 100644 --- a/docs/testing/TEST_IDS.md +++ b/docs/testing/TEST_IDS.md @@ -1077,6 +1077,7 @@ Every control has a test ID: **997** buttons, text boxes, selects and textareas. - `data-peek-apply-{id}` · button · Apply - `data-peek-base-filter-{id}` · div - `data-peek-base-filter-clear-{id}` · button · Clear key filter and fetch all rows + - `data-peek-body-{id}` · div - `data-peek-clear-key-{id}` · button · Clear key filter and fetch all rows - `data-peek-close` · button · Close data peek - `data-peek-close-panel-{id}` · button · Close this panel From ef180984ac6f3e329ce3366c4b712e10182d58f5 Mon Sep 17 00:00:00 2001 From: huyplb Date: Sun, 25 Oct 2026 15:50:29 -0600 Subject: [PATCH 4/4] test(e2e): register the data-peek grid suite with the runner Co-Authored-By: Claude Opus 5.5 --- apps/e2e/scripts/run-all.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/e2e/scripts/run-all.mjs b/apps/e2e/scripts/run-all.mjs index f9c879d89..1b6cd05d3 100644 --- a/apps/e2e/scripts/run-all.mjs +++ b/apps/e2e/scripts/run-all.mjs @@ -126,6 +126,7 @@ const ALWAYS = [ 'src/tests/sql-editor-run-rail.test.ts', 'src/tests/sql-editor-peek-row-form.test.ts', 'src/tests/sql-editor-peek-stacking.test.ts', + 'src/tests/sql-editor-data-peek-grid.test.ts', 'src/tests/sql-editor-compare-multi-target.test.ts', 'src/tests/sql-editor-join-typing.test.ts', ].join(' '),