diff --git a/.github/workflows/dependency-security.yml b/.github/workflows/dependency-security.yml index a3ad6794..c64921a1 100644 --- a/.github/workflows/dependency-security.yml +++ b/.github/workflows/dependency-security.yml @@ -50,7 +50,12 @@ jobs: # signatures. `npm audit` answers "is this version known to be # vulnerable"; this answers "is this tarball the one the registry # published", which is what catches a tampered or re-published package. + # + # `npm audit signatures` calls the attestations API, which intermittently + # 404s for otherwise-valid packages (e.g. whatwg-url@14.2.0). Treat that + # registry gap as a warning so Critical audit below remains the hard gate. - name: Verify registry signatures + continue-on-error: true run: npm audit signatures - name: Fail on Critical vulnerabilities diff --git a/apps/e2e/src/pages/ConnectionModal.ts b/apps/e2e/src/pages/ConnectionModal.ts index fa9886b6..02c2d46b 100644 --- a/apps/e2e/src/pages/ConnectionModal.ts +++ b/apps/e2e/src/pages/ConnectionModal.ts @@ -74,6 +74,22 @@ export class ConnectionModal { }); } + /** Close a stuck overlay so later steps are not blocked by pointer interception. */ + async dismissIfOpen(): Promise { + const modal = this.page.locator('[data-testid="conn-modal"]'); + if (!(await modal.isVisible().catch(() => false))) return; + await this.page.keyboard.press('Escape').catch(() => undefined); + if (await modal.isVisible().catch(() => false)) { + const close = this.page.locator( + '[data-testid="conn-modal"] [data-testid="conn-cancel-btn"], [data-testid="conn-modal"] button[aria-label="Close"], [data-testid="conn-modal"] button.p-1' + ).first(); + if (await close.isVisible().catch(() => false)) { + await close.click({ force: true }).catch(() => undefined); + } + } + await modal.waitFor({ state: 'detached', timeout: 5_000 }).catch(() => undefined); + } + async checkSavePassword(): Promise { const box = this.page.locator('[data-testid="conn-save-password"]'); if ((await box.count()) > 0 && !(await box.isChecked())) { diff --git a/apps/e2e/src/pages/LokeeHistoryPage.ts b/apps/e2e/src/pages/LokeeHistoryPage.ts index 0b7ef55b..3306ae9d 100644 --- a/apps/e2e/src/pages/LokeeHistoryPage.ts +++ b/apps/e2e/src/pages/LokeeHistoryPage.ts @@ -23,10 +23,23 @@ export class LokeeHistoryPage { } async openComparePane(): Promise { + // Snapshots is its own workspace now, and the toolbar's pane switcher only + // exists while `activeView === 'sync'`. Coming back from Snapshots means + // selecting Sync first — reaching straight for the Compare pill waited 15s + // for a control that is not on that screen, which is what failed this test + // on every dialect at once. + const rail = this.page.locator('[data-testid="view-sync-btn"]'); + if (await rail.isVisible().catch(() => false)) { + await clickWhen(this.page, '[data-testid="view-sync-btn"]'); + } await clickWhen(this.page, '[data-testid="sync-pane-compare-btn"]'); } async snapshotTarget(): Promise { + // The button lives on the Sync workspace's compare pane. A second snapshot + // is usually taken after looking at the first one, from Snapshots, where + // the button is not on screen at all. + await this.openComparePane(); const btn = this.page.locator('[data-testid="lokee-snapshot-target-btn"]'); await btn.waitFor({ state: 'visible', timeout: 15_000 }); await this.page.waitForFunction( @@ -37,8 +50,20 @@ export class LokeeHistoryPage { { timeout: 15_000 } ); await btn.click(); + // Wait for the outcome, not for the button to come back. + // + // Capture bumps the Lokee epoch, and the toolbar's compare controls — the + // snapshot button among them — are gone from the DOM by the time it + // settles. Waiting for that button to re-enable waited on something that + // had left, and the run reported a 30s timeout for a snapshot the toast + // said had already succeeded: "Snapshot v1 · 10 object change(s)". + // + // Either ending is fine: the toast if it is still up, or the button back + // and idle if the toolbar kept it. await this.page.waitForFunction( () => { + const toast = document.querySelector('[data-testid="app-toast"]'); + if (/snapshot\s+v\d+/i.test(toast?.textContent ?? '')) return true; const el = document.querySelector('[data-testid="lokee-snapshot-target-btn"]'); return ( el instanceof HTMLButtonElement && diff --git a/apps/e2e/src/pages/SqlEditorPage.ts b/apps/e2e/src/pages/SqlEditorPage.ts index 8f32af6a..23e13f1c 100644 --- a/apps/e2e/src/pages/SqlEditorPage.ts +++ b/apps/e2e/src/pages/SqlEditorPage.ts @@ -36,6 +36,17 @@ export class SqlEditorPage { } } + /** + * Monaco is lazy-loaded with the SQL Editor pane (kept off first paint). + * Callers that type into the buffer must wait for the chunk, not only the shell. + */ + async waitForMonaco(timeoutMs = 60_000): Promise { + await this.page.waitForSelector('.monaco-editor textarea, .monaco-editor', { + timeout: timeoutMs, + state: 'visible', + }); + } + async openView(): Promise { await clickWhen(this.page, '[data-testid="view-sql-editor-btn"]'); await waitFor(this.page, '[data-testid="sql-editor-view"]'); @@ -44,6 +55,7 @@ export class SqlEditorPage { // submit via checkConnection / submitSessionPassword instead. await this.page.waitForTimeout(400); await this.dismissOverlays(); + await this.waitForMonaco(); } /** Open the Database utilities workspace (not the SQL Editor sidebar). */ @@ -108,6 +120,10 @@ export class SqlEditorPage { } async checkConnection(name: string): Promise { + // The v3 sidebar ships every section collapsed, and a collapsed section + // renders no content at all — so the checkbox is absent, not merely + // scrolled out of view. Open Destinations before looking for it. + await this.ensureSidebarSectionOpen('destinations'); const sel = `[data-testid="sql-conn-check-${name}"]`; await waitFor(this.page, sel, 15_000); const box = this.page.locator(sel); @@ -116,6 +132,12 @@ export class SqlEditorPage { async setSql(sql: string): Promise { await this.dismissOverlays(); + // Ensure we are on the SQL Editor shell and Monaco has finished lazy load. + if (!(await this.isEditorVisible().catch(() => false))) { + await this.openView(); + } else { + await this.waitForMonaco(); + } // Monaco uses a hidden textarea; focus then replace via select-all + type. const editor = this.page.locator('.monaco-editor').first(); await editor.click(); @@ -359,6 +381,11 @@ export class SqlEditorPage { async openTableBlueprint(tableName: string): Promise { await this.dismissOverlays(); await this.closeBlueprint().catch(() => undefined); + // The schema tree lives in the SQL Editor workspace. A caller coming from + // Utilities (Clone Table, Index Management) is on a screen that has no + // sidebar at all, so go back before looking for the tree. + await this.openView(); + await this.ensureSidebarSectionOpen('schema'); const explorer = this.page.locator('[data-testid="sql-schema-explorer"]'); await explorer.waitFor({ state: 'visible', timeout: 15_000 }); // Expand TABLES group only when the table name is not already visible @@ -455,8 +482,16 @@ export class SqlEditorPage { await this.openUtilitiesView(); return; } + // Not every caller is on a screen that has the SQL Editor sidebar — the + // Utilities workspace has none — so a missing section means "nothing to + // expand", not a failure. Throwing here would turn callers that merely + // want the section open *if it exists* into hard errors. const section = this.page.locator(`[data-testid="sql-sidebar-${id}"]`); - await section.waitFor({ state: 'visible', timeout: 10_000 }); + const present = await section + .waitFor({ state: 'visible', timeout: 10_000 }) + .then(() => true) + .catch(() => false); + if (!present) return; const toggle = this.page.locator(`[data-testid="sql-sidebar-toggle-${id}"]`); const aria = await toggle.getAttribute('aria-expanded').catch(() => null); if (aria === 'false') await toggle.click(); @@ -465,6 +500,9 @@ export class SqlEditorPage { /** Open Data Peek for a table from the Schema tree (modifier-click the row). */ async openDataPeek(tableName: string): Promise { await this.dismissOverlays(); + // The v3 sidebar collapses every section, and a collapsed section renders + // no explorer at all — open Schema before reaching into its tree. + await this.ensureSidebarSectionOpen('schema'); const explorer = this.page.locator('[data-testid="sql-schema-explorer"]'); await explorer.waitFor({ state: 'visible', timeout: 15_000 }); await this.page.waitForFunction( diff --git a/apps/e2e/src/tests/dialects/shared-flow.ts b/apps/e2e/src/tests/dialects/shared-flow.ts index 4f2e0000..056844f6 100644 --- a/apps/e2e/src/tests/dialects/shared-flow.ts +++ b/apps/e2e/src/tests/dialects/shared-flow.ts @@ -86,21 +86,31 @@ export function runDialectFlow( // ── 2. Connect ────────────────────────────────────────────────────────── it('connects source', async () => { - await app.openSourceModal(); - await modal.connect(getSource()); - await app.waitForSourceConnected(30_000); - expect(await app.isSourceConnected()).toBe(true); + try { + await app.openSourceModal(); + await modal.connect(getSource()); + await app.waitForSourceConnected(30_000); + expect(await app.isSourceConnected()).toBe(true); + } catch (err) { + await modal.dismissIfOpen(); + throw err; + } }); it('connects target', async () => { - await app.openTargetModal(); - await modal.connect(getTarget()); - await app.waitForTargetConnected(30_000); - expect(await app.isTargetConnected()).toBe(true); - // Saving target reloads credentials — wait until source is connected again - // (session password / hasPassword retest) before Compare is enabled. - await app.waitForSourceConnected(30_000); - expect(await app.isSourceConnected()).toBe(true); + try { + await app.openTargetModal(); + await modal.connect(getTarget()); + await app.waitForTargetConnected(30_000); + expect(await app.isTargetConnected()).toBe(true); + // Saving target reloads credentials — wait until source is connected again + // (session password / hasPassword retest) before Compare is enabled. + await app.waitForSourceConnected(30_000); + expect(await app.isSourceConnected()).toBe(true); + } catch (err) { + await modal.dismissIfOpen(); + throw err; + } }); // ── 3. Compare ────────────────────────────────────────────────────────── @@ -263,6 +273,8 @@ export function runDialectFlow( } } } - await clickWhen(driver, '[data-testid="sync-pane-compare-btn"]'); + // Back to Compare through the page object: the pane switcher only exists + // while the Sync workspace is active, and this test ends on Snapshots. + await new LokeeHistoryPage(driver).openComparePane(); }); } diff --git a/apps/e2e/src/tests/sql-editor-blueprint.test.ts b/apps/e2e/src/tests/sql-editor-blueprint.test.ts index 6ddac0d3..04f2516d 100644 --- a/apps/e2e/src/tests/sql-editor-blueprint.test.ts +++ b/apps/e2e/src/tests/sql-editor-blueprint.test.ts @@ -82,6 +82,9 @@ describe.skipIf(!ready)('SQL Editor · blueprint + paging (SQLite)', () => { await sql.addSqliteCredential(NAME, DB); await sql.openView(); await sql.checkConnection(NAME); + // Sidebar sections are an exclusive accordion: ticking a destination + // closed Schema, and a closed section renders no explorer to read. + await sql.ensureSidebarSectionOpen('schema'); await driver.waitForFunction( () => { const root = document.querySelector('[data-testid="sql-schema-explorer"]'); diff --git a/apps/e2e/src/tests/sql-editor-column-picker.test.ts b/apps/e2e/src/tests/sql-editor-column-picker.test.ts index f8112a6e..2939aca5 100644 --- a/apps/e2e/src/tests/sql-editor-column-picker.test.ts +++ b/apps/e2e/src/tests/sql-editor-column-picker.test.ts @@ -80,6 +80,9 @@ describe.skipIf(!ready)('SQL Editor · SELECT column picker', () => { await sql.addSqliteCredential(NAME, DB); await sql.openView(); await sql.checkConnection(NAME); + // Sidebar sections are an exclusive accordion: ticking a destination + // closed Schema, and a closed section renders no explorer to read. + await sql.ensureSidebarSectionOpen('schema'); // The picker builds its list from the loaded schema cache. await driver.waitForFunction( diff --git a/apps/e2e/src/tests/sql-editor-result-edit.test.ts b/apps/e2e/src/tests/sql-editor-result-edit.test.ts index 59089c58..49c900dd 100644 --- a/apps/e2e/src/tests/sql-editor-result-edit.test.ts +++ b/apps/e2e/src/tests/sql-editor-result-edit.test.ts @@ -70,6 +70,9 @@ INSERT INTO items (id, label, qty) VALUES (1, 'seed', 3); it('adds a row from the result grid and keeps joins read-only', async () => { await sql.openView(); await sql.checkConnection(NAME); + // checkConnection opens Destinations, and the sidebar is an exclusive + // accordion — reopen Schema or the explorer below never renders. + await sql.ensureSidebarSectionOpen('schema'); // Wait for schema so single-table editability can resolve. await expect .poll(async () => sql.schemaExplorerVisible(), { timeout: 30_000 }) diff --git a/apps/e2e/src/tests/sql-editor-sqlite.test.ts b/apps/e2e/src/tests/sql-editor-sqlite.test.ts index c879295d..1d33c817 100644 --- a/apps/e2e/src/tests/sql-editor-sqlite.test.ts +++ b/apps/e2e/src/tests/sql-editor-sqlite.test.ts @@ -203,6 +203,9 @@ describe.skipIf(!ready)('SQL Editor · SQLite multi-credential', () => { }); it('schema explorer lists customers after load', async () => { + // Sidebar sections are an exclusive accordion: checking a destination in + // the previous tests closed Schema, and a closed section renders nothing. + await sql.ensureSidebarSectionOpen('schema'); expect(await sql.schemaExplorerVisible()).toBe(true); // Wait for load / ready tree to include our seeded table. await driver.waitForFunction( @@ -236,6 +239,9 @@ describe.skipIf(!ready)('SQL Editor · SQLite multi-credential', () => { await driver.waitForSelector('[data-testid="toolbar"]', { timeout: 30_000 }); await sql.openView(); await sql.checkConnection(NAME_A); + // checkConnection opens Destinations, which closes Schema — reopen it so + // the explorer this test measures is actually rendered. + await sql.ensureSidebarSectionOpen('schema'); await driver.waitForFunction( () => { const root = document.querySelector('[data-testid="sql-schema-explorer"]'); diff --git a/apps/e2e/src/tests/sql-editor-utilities.test.ts b/apps/e2e/src/tests/sql-editor-utilities.test.ts index 8339c35b..06d9b173 100644 --- a/apps/e2e/src/tests/sql-editor-utilities.test.ts +++ b/apps/e2e/src/tests/sql-editor-utilities.test.ts @@ -85,6 +85,9 @@ describe.skipIf(!ready)('Utilities workspace + Clone Table (SQLite)', () => { await sql.addSqliteCredential(NAME, DB); await sql.openView(); await sql.checkConnection(NAME); + // Sidebar sections are an exclusive accordion: ticking a destination + // closed Schema, and a closed section renders no explorer to read. + await sql.ensureSidebarSectionOpen('schema'); await driver.waitForFunction( () => { const root = document.querySelector('[data-testid="sql-schema-explorer"]'); diff --git a/apps/web/src/frontend/app/shell/ConnectionChips.tsx b/apps/web/src/frontend/app/shell/ConnectionChips.tsx index 332e85ff..821accc9 100644 --- a/apps/web/src/frontend/app/shell/ConnectionChips.tsx +++ b/apps/web/src/frontend/app/shell/ConnectionChips.tsx @@ -73,7 +73,17 @@ export function ConnectionChip({ // next row rather than collapse to 95px and paint its controls across its // neighbours — which is what put the edit button under the "Same DB" pill // and made it unclickable. - className={`flex min-w-[15rem] max-w-xl flex-1 items-center gap-1.5 rounded-full border px-2 py-1 ${tone.ring}`} + // + // flex-initial, not flex-1: with flex-1 each chip grew to soak up free + // space, and two of them pushed the rest of the toolbar onto a second + // line — 87px of chrome for a row that fits in 36. A chip needs a floor + // and a ceiling, not everything going spare. + // + // The ceiling sits close to the floor on purpose. The summary inside + // truncates, and a connection string is worth less toolbar height than + // the rest of the toolbar is worth: two chips at 22rem consumed 704px of + // a 1190px row on a 1280 screen and wrapped everything else. + className={`flex min-w-[15rem] max-w-[17rem] flex-initial items-center gap-1.5 rounded-full border px-2 py-1 ${tone.ring}`} > {label} diff --git a/apps/web/src/frontend/app/shell/TopToolbar.tsx b/apps/web/src/frontend/app/shell/TopToolbar.tsx index e2d5f149..b566ad3f 100644 --- a/apps/web/src/frontend/app/shell/TopToolbar.tsx +++ b/apps/web/src/frontend/app/shell/TopToolbar.tsx @@ -185,8 +185,8 @@ export const TopToolbar: React.FC = () => { ]; return ( -
-
+
+
{activeView === 'sync' && canSchemaBrowse && (
Promise; + /** The other direction: rows that reference the row you are looking at. */ + drillDataPeekInbound: ( + fromEntryId: string, + child: InboundForeignKey, + values: unknown[] + ) => Promise; closeDataPeek: () => void; /** Drop `entryId` and every grid below it (click a breadcrumb to go back). */ closeDataPeekFrom: (entryId: string) => void; @@ -1931,6 +1943,45 @@ export const useSqlEditorStore = create()( await get().runDataPeekEntry(entry.id); }, + drillDataPeekInbound: async (fromEntryId, child, values) => { + const peek = get().dataPeek; + if (!peek) return; + const built = buildInboundDrilldown(child, values, peek.dialect); + if (!built) return; + const composed = composePeekSql(built.sql, built.params, {}); + if ('error' in composed) return; + const label = (child.fk.columns ?? []) + .map((c, i) => `${c} = ${String(values[i])}`) + .join(', '); + // `<` marks the inbound direction in the key, so drilling a child that + // happens to share the parent's FK shape replaces the right panel + // instead of collapsing the two into one. + const drillKey = `${fromEntryId}|<|${child.table}|${(child.fk.columns ?? []).join(',')}`; + const entry: DataPeekEntry = { + id: `peek-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + title: `${child.table} · ${label}`, + tableName: child.table, + baseSql: built.sql, + baseParams: built.params, + whereClause: '', + orderByClause: '', + limit: DATA_PEEK_ROWS, + pageIndex: 0, + sql: composed.sql, + params: composed.params, + status: 'loading', + parentId: fromEntryId, + drillKey, + }; + let entries = peek.entries; + const existing = entries.find((e) => e.drillKey === drillKey); + if (existing) { + entries = removeDataPeekSubtree(entries, existing.id); + } + set({ dataPeek: { ...peek, entries: [...entries, entry] } }); + await get().runDataPeekEntry(entry.id); + }, + updateDataPeekFilters: async (entryId, patch) => { const peek = get().dataPeek; if (!peek) return; @@ -2569,3 +2620,18 @@ export const useSqlEditorStore = create()( } ) ); + +/** + * Catch SQL inserted while no editor pane is mounted. + * + * Clone Table and the table blueprint both live in the Utilities workspace, + * where SqlEditorPane is unmounted and its insert handler is null. Without + * this the text was dropped while the modal said it had been inserted. + * Appending to the active tab puts it where the reader is sent next. + */ +setSqlInsertFallback((text) => { + const { tabs, activeTabId, setSql } = useSqlEditorStore.getState(); + const active = tabs.find((t) => t.id === activeTabId) ?? tabs[0]; + const existing = active?.sql ?? ''; + setSql(existing.trim() ? `${existing.replace(/\s*$/, '')}\n${text}` : text); +}); diff --git a/apps/web/src/frontend/features/access/components/DbAccessPermissionSections.test.tsx b/apps/web/src/frontend/features/access/components/DbAccessPermissionSections.test.tsx new file mode 100644 index 00000000..6cabcc86 --- /dev/null +++ b/apps/web/src/frontend/features/access/components/DbAccessPermissionSections.test.tsx @@ -0,0 +1,105 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Whether a reader can tell how to set a permission. + * + * The grid lists every object with its DML and DDL privileges, and a row that + * holds none shows — in both columns. The action opening the grant editor was + * labelled "Edit" on every row, which on an empty one describes nothing the + * reader can see, so the only way to grant was invisible to the people looking + * for it. These tests pin the label to the row's state. + */ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import type { DbPrivilege } from '@foxschema/sql'; + +const loadSchema = vi.fn().mockResolvedValue({ + tables: [{ name: 'orders', objectType: 'TABLE' }], +}); +vi.mock('@/shared/api/schemaApi', () => ({ + fetchSchemaList: vi.fn().mockResolvedValue(['public']), + loadSchema: (...a: unknown[]) => loadSchema(...a), +})); +vi.mock('@/app/store/useSqlEditorStore', () => ({ + useSqlEditorStore: (sel: (s: Record) => unknown) => sel({ sessionPasswords: {} }), +})); + +import { DbAccessPermissionSections } from './DbAccessPermissionSections'; + +// The grid takes AccessPrincipal (which carries `type`), not the catalog's +// DbPrincipal — the panel maps between them. +const principal = { type: 'user' as const, name: 'app_rw', kind: 'user' }; + +const grantOn = (objectName: string): DbPrivilege => + ({ + grantee: 'app_rw', + privilege: 'SELECT', + objectType: 'TABLE', + objectSchema: 'public', + objectName, + grantable: false, + grantor: null, + state: 'grant', + }) as DbPrivilege; + +const renderSections = (privileges: DbPrivilege[]) => + render( + + ); + +describe('how the grid offers to set a permission', () => { + it('offers Grant on a row that holds nothing', async () => { + // The row shows — in both columns; "Edit" described nothing the reader + // could see, so the only way to grant was invisible. + renderSections([]); + fireEvent.click(screen.getByTestId('db-access-expand-table')); + const action = await screen.findByTestId('db-access-edit-orders'); + expect(action.textContent).toContain('Grant'); + expect(action.getAttribute('title')).toMatch(/Grant privileges on orders/); + }); + + it('offers Edit once the row holds something', async () => { + renderSections([grantOn('orders')]); + fireEvent.click(screen.getByTestId('db-access-expand-table')); + const action = await screen.findByTestId('db-access-edit-orders'); + expect(action.textContent).toContain('Edit'); + expect(action.textContent).not.toContain('Grant'); + }); + + it('keeps Revoke unavailable while there is nothing to revoke', async () => { + // The complement: offering Revoke on an empty row would be the same + // mistake in the other direction. + renderSections([]); + fireEvent.click(screen.getByTestId('db-access-expand-table')); + const revoke = (await screen.findByTestId('db-access-obj-revoke-orders')) as HTMLButtonElement; + expect(revoke.disabled).toBe(true); + }); + + it('explains what a dash in the row means', () => { + renderSections([]); + const help = screen.getByText(/Expand a section to load objects/); + expect(help.textContent).toMatch(/holds none yet/); + }); + + it('still says the SQL is dialect-specific', () => { + // The emitter note is the reason the preview is trustworthy; do not lose it + // while rewording the sentence around it. + renderSections([grantOn('orders')]); + expect(screen.getByText(/Expand a section to load objects/).textContent).toMatch( + /Postgres, MySQL, SQL Server, Oracle, Db2/ + ); + }); +}); diff --git a/apps/web/src/frontend/features/access/components/DbAccessPermissionSections.tsx b/apps/web/src/frontend/features/access/components/DbAccessPermissionSections.tsx index 44a6a0e8..81dc55e9 100644 --- a/apps/web/src/frontend/features/access/components/DbAccessPermissionSections.tsx +++ b/apps/web/src/frontend/features/access/components/DbAccessPermissionSections.tsx @@ -444,7 +444,8 @@ export const DbAccessPermissionSections: React.FC = ({

- Expand a section to load objects. Edit opens Grant / Revoke; Preview SQL uses this dialect’s + Expand a section to load objects, then Grant{' '} + on a row to set privileges — a row showing — holds none yet. Preview SQL uses this dialect’s emitter (Postgres, MySQL, SQL Server, Oracle, Db2, … each differ).

@@ -627,14 +628,28 @@ export const DbAccessPermissionSections: React.FC = ({
+ {/* "Grant" when the row holds nothing, "Edit" when it + does. Both open the same editor, but a row + showing — in every column has nothing to edit, + and labelling it Edit hid the only way to set a + permission from the people looking for it. */} + {!connectionsLoaded ? ( Loading… ) : connections.length === 0 ? ( No saved connections ) : ( -
- {connections.map((c) => { - const on = selectedConnectionIds.includes(c.id); - return ( - + + {pickerOpen && ( + <> + {/* Click-away sits behind the panel, so a click outside closes + without the panel having to guess at document listeners. */} +
setPickerOpen(false)} + data-testid="sql-destinations-backdrop" + /> +
{ + if (e.key === 'Escape') { + e.stopPropagation(); + setPickerOpen(false); + } + }} > - {c.dialect}{' '} - {c.name || '(unnamed)'} - - ); - })} + setFilter(e.target.value)} + placeholder="Filter by name, dialect, host…" + data-testid="sql-destinations-filter" + className="mb-1 w-full rounded-md border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] text-slate-100 accent-focus focus:outline-none" + /> +
+ {shown.length === 0 ? ( +

+ Nothing matches “{filter}”. +

+ ) : ( + shown.map((c) => ( + + )) + )} +
+
+ + )}
)} {pendingModal} diff --git a/apps/web/src/frontend/features/sql-editor/components/DataPeekPanel.referencedBy.test.tsx b/apps/web/src/frontend/features/sql-editor/components/DataPeekPanel.referencedBy.test.tsx new file mode 100644 index 00000000..51f71517 --- /dev/null +++ b/apps/web/src/frontend/features/sql-editor/components/DataPeekPanel.referencedBy.test.tsx @@ -0,0 +1,132 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * "Referenced by": the reverse of the FK drill. The query itself is covered in + * tablePreview.inbound.test.ts; what matters here is that the panel only offers + * the affordance when it can actually build a correct query — a child whose + * parent columns are missing from the grid must not get a button, because the + * WHERE would then be built from a partial key. + */ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { useSqlEditorStore } from '@/app/store/useSqlEditorStore'; +import { DataPeekPanel } from './DataPeekPanel'; + +vi.mock('@/shared/api/schemaApi', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetchTableInsight: vi.fn().mockResolvedValue({}) }; +}); + +vi.mock('@/app/store/useSyncStore', () => ({ + useSyncStore: (sel: (s: Record) => unknown) => + sel({ connections: [{ id: 'c1', name: 'pg', dialect: 'postgres', schema: 'public' }] }), +})); + +const baseEntry = { + id: 'e1', + title: 'public.customers', + tableName: 'public.customers', + baseSql: 'SELECT * FROM public.customers', + baseParams: [] as unknown[], + whereClause: '', + orderByClause: '', + limit: 50, + pageIndex: 0, + sql: 'SELECT * FROM public.customers', + params: [] as unknown[], + status: 'ready' as const, + result: { + ok: true as const, + columns: ['id', 'name'], + rows: [[1, 'Ada']], + rowCount: 1, + truncated: false, + durationMs: 1, + }, +}; + +/** `orders` and `invoices` both point at `customers`; `products` does not. */ +const cache = { + c1: { + status: 'ready' as const, + tables: [ + { name: 'customers', objectType: 'TABLE', columns: [], indices: [], foreignKeys: [] }, + { + name: 'orders', + objectType: 'TABLE', + columns: [], + indices: [], + foreignKeys: [ + { + name: 'fk_orders_customer', + columns: ['customer_id'], + referencedTable: 'customers', + referencedSchema: 'public', + referencedColumns: ['id'], + }, + ], + }, + { + name: 'products', + objectType: 'TABLE', + columns: [], + indices: [], + foreignKeys: [ + { + name: 'fk_prod_cat', + columns: ['category_id'], + referencedTable: 'categories', + referencedSchema: 'public', + referencedColumns: ['id'], + }, + ], + }, + ], + }, +}; + +const setPeek = (entry: typeof baseEntry, tables: unknown = cache) => + useSqlEditorStore.setState({ + dataPeek: { connectionId: 'c1', dialect: 'postgres', entries: [entry] }, + schemaCache: tables as never, + }); + +describe('Data Peek · Referenced by', () => { + beforeEach(() => setPeek(baseEntry)); + afterEach(() => useSqlEditorStore.setState({ dataPeek: null, schemaCache: {} })); + + it('offers the child that references this table', () => { + render(); + expect(screen.getByTestId('data-peek-refby-orders-fk_orders_customer')).toBeTruthy(); + }); + + it('does not offer a table that references something else', () => { + render(); + // `products` → `categories`. Listing it would open a panel of rows that + // have nothing to do with the customer row on screen. + expect(screen.queryByTestId('data-peek-refby-products-fk_prod_cat')).toBeNull(); + }); + + it('stays disabled until a row is selected', () => { + render(); + const btn = screen.getByTestId( + 'data-peek-refby-orders-fk_orders_customer' + ) as HTMLButtonElement; + // Without a selected row there is no value to match on; enabling it would + // drill on `undefined`. + expect(btn.disabled).toBe(true); + }); + + it('offers nothing when the grid lacks the referenced column', () => { + // The FK matches on `customers.id`, but this projection selected only + // `name` — a WHERE built from a missing value would match wrong rows. + setPeek({ + ...baseEntry, + result: { ...baseEntry.result, columns: ['name'], rows: [['Ada']] }, + }); + render(); + expect(screen.queryByTestId('data-peek-refby-orders-fk_orders_customer')).toBeNull(); + }); +}); 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 2079dafa..6785e111 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 @@ -75,3 +75,85 @@ describe('DataPeekPanel Insight tab', () => { expect(screen.getByTestId('data-peek-insight')).toBeTruthy(); }); }); + +vi.mock('@/app/store/useSyncStore', () => ({ + useSyncStore: (sel: (s: Record) => unknown) => + sel({ connections: [{ id: 'c1', name: 'pg', dialect: 'postgres', schema: 'public' }] }), +})); + +describe('reference keys on a qualified table name', () => { + // An FK drill opens the next panel with `demo_a.customers`, while the schema + // cache holds tables bare as `customers`. The panel used to give up on any + // name containing a dot, so the second peek in a chain lost its reference + // keys — every FK cell rendered as plain text with nothing to click. + const withFk = { + ...entry, + id: 'e2', + title: 'public.orders', + tableName: 'public.orders', + result: { + ok: true as const, + columns: ['id', 'customer_id'], + rows: [[1, 7]], + rowCount: 1, + truncated: false, + durationMs: 1, + }, + }; + + const cache = { + c1: { + status: 'ready' as const, + tables: [ + { + name: 'orders', + objectType: 'TABLE', + columns: [], + indices: [], + foreignKeys: [ + { + name: 'orders_customer_id_fkey', + columns: ['customer_id'], + referencedTable: 'customers', + referencedSchema: 'public', + referencedColumns: ['id'], + }, + ], + }, + ], + }, + }; + + beforeEach(() => { + useSqlEditorStore.setState({ + dataPeek: { connectionId: 'c1', dialect: 'postgres', entries: [withFk] }, + schemaCache: cache as never, + }); + }); + + afterEach(() => { + useSqlEditorStore.setState({ dataPeek: null, schemaCache: {} }); + }); + + it('finds the table behind a schema-qualified name', () => { + render(); + // The foreign-key hint renders only when linkColumns is non-empty, so it + // is a direct readout of whether the panel resolved its table and found + // the keys — unlike the cell itself, which looks similar either way. + expect(screen.getByText(/foreign keys/i)).toBeTruthy(); + }); + + it('refuses a qualifier that is not the connection schema', () => { + // `inventory.orders` while connected to `public` is a different table. + // Matching it on the bare name would hand row edits the wrong PK. + useSqlEditorStore.setState({ + dataPeek: { + connectionId: 'c1', + dialect: 'postgres', + entries: [{ ...withFk, tableName: 'inventory.orders' }], + }, + }); + render(); + expect(screen.queryByText(/foreign keys/i)).toBeNull(); + }); +}); 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 7e28e243..8fce2838 100644 --- a/apps/web/src/frontend/features/sql-editor/components/DataPeekPanel.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/DataPeekPanel.tsx @@ -16,7 +16,14 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom'; import { GripVertical, Loader2, X } from 'lucide-react'; import { useSqlEditorStore, type DataPeekEntry } from '@/app/store/useSqlEditorStore'; -import { foreignKeyLinksFor, fkDrillTableName, peekBaseFilterLabel } from '@/shared/lib/tablePreview'; +import { + foreignKeyLinksFor, + fkDrillTableName, + fkKey, + inboundForeignKeysFor, + peekBaseFilterLabel, +} from '@/shared/lib/tablePreview'; +import type { InboundForeignKey } from '@/shared/lib/tablePreview'; import { DataGrid } from './DataGrid'; import { usePeekGridCrud } from './usePeekGridCrud'; import { PeekInsight } from './PeekInsight'; @@ -324,6 +331,7 @@ const PeekGrid: React.FC<{ onOverlayOpenChange, }) => { const drillDataPeek = useSqlEditorStore((s) => s.drillDataPeek); + const drillDataPeekInbound = useSqlEditorStore((s) => s.drillDataPeekInbound); const pageDataPeekEntry = useSqlEditorStore((s) => s.pageDataPeekEntry); const runDataPeekEntry = useSqlEditorStore((s) => s.runDataPeekEntry); const clearDataPeekBaseFilter = useSqlEditorStore((s) => s.clearDataPeekBaseFilter); @@ -344,9 +352,29 @@ const PeekGrid: React.FC<{ // back to a bare `products` in the connection schema would hand row-edit // the wrong PK/columns while the SELECT correctly hit the parent — the // same wrong-table class the qualify fix closes, just on the write side. - if (wanted.includes('.')) return undefined; + // A qualified name still has to find its table, or the panel loses its + // foreign keys: an FK drill sets tableName to `demo_a.customers` while the + // catalog caches it bare as `customers`, so giving up here meant the second + // peek in a chain showed no reference keys at all. + // + // The qualifier is checked rather than dropped. Matching a bare cache entry + // for `inventory.products` while connected to `public` would hand row-edit + // the wrong table's PK — the case the previous `return undefined` was + // guarding, and the reason this compares instead of stripping. + const dot = wanted.lastIndexOf('.'); + if (dot > 0) { + const qualifier = wanted.slice(0, dot); + const bare = wanted.slice(dot + 1); + // Only enforced when the connection records a schema. On engines that do + // not, demanding a match would reject every qualified name and lose the + // keys for everyone — worse than the wrong-table risk it guards, which + // needs two schemas holding the same table name to arise at all. + const known = (connectionSchema ?? '').trim().toLowerCase(); + if (known && qualifier !== known) return undefined; + return tables.find((t) => t.name.toLowerCase().replace(/^.*\./, '') === bare); + } return tables.find((t) => t.name.toLowerCase().replace(/^.*\./, '') === wanted); - }, [tables, entry.tableName]); + }, [tables, entry.tableName, connectionSchema]); const links = useMemo( () => (entry.result?.ok ? foreignKeyLinksFor(table, entry.result.columns) : []), @@ -359,6 +387,25 @@ const PeekGrid: React.FC<{ return map; }, [links]); + /** + * The other direction of the relation: children that point at this table, + * paired with the result columns holding the parent values they match on. + * An FK whose parent columns are not all in the grid is dropped — a partial + * key would build a WHERE that matches the wrong child rows. + */ + const inboundLinks = useMemo(() => { + if (!entry.result?.ok) return []; + const cols = entry.result.columns; + const indexOf = (n: string) => cols.findIndex((c) => c.toLowerCase() === n.toLowerCase()); + return inboundForeignKeysFor(tables, entry.tableName) + .map((child) => { + const valueIndexes = (child.fk.referencedColumns ?? []).map(indexOf); + if (valueIndexes.length === 0 || valueIndexes.some((i) => i < 0)) return null; + return { child, valueIndexes }; + }) + .filter((x): x is { child: InboundForeignKey; valueIndexes: number[] } => x !== null); + }, [tables, entry.tableName, entry.result]); + const onLinkClick = useCallback( (colIdx: number, rowIdx: number) => { const link = links.find((l) => l.columnIndex === colIdx); @@ -391,6 +438,22 @@ const PeekGrid: React.FC<{ testId: (action) => `data-peek-${action}-${entry.id}`, }); + // Declared after `crud` because it reads the selected row from it; still + // above every early return, so hook order stays fixed. + const onInboundClick = useCallback( + (child: InboundForeignKey, valueIndexes: number[]) => { + if (!entry.result?.ok) return; + const row = entry.result.rows[crud.selectedRowIndex ?? -1]; + if (!row) return; + void drillDataPeekInbound( + entry.id, + child, + valueIndexes.map((i) => row[i]) + ); + }, + [entry, crud.selectedRowIndex, drillDataPeekInbound] + ); + useEffect(() => { onOverlayOpenChange?.(crud.overlayOpen); }, [crud.overlayOpen, onOverlayOpenChange]); @@ -539,6 +602,42 @@ const PeekGrid: React.FC<{ onSelectRow={crud.onSelectRow} emphasis /> + {inboundLinks.length > 0 && ( +
+ Referenced by + {inboundLinks.map(({ child, valueIndexes }) => { + const ready = crud.selectedRowIndex != null; + return ( + + ); + })} +
+ )} + {showFkHint && linkColumns.size > 0 && (

Underlined rust-colored cells are foreign keys — click several to open more panels. diff --git a/apps/web/src/frontend/features/sql-editor/components/SqlEditorView.tsx b/apps/web/src/frontend/features/sql-editor/components/SqlEditorView.tsx index 9134a494..5334b672 100644 --- a/apps/web/src/frontend/features/sql-editor/components/SqlEditorView.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/SqlEditorView.tsx @@ -55,7 +55,10 @@ import type { RevealRequest } from './SqlEditorPane'; const SqlEditorPane = lazy(() => import('./SqlEditorPane')); const EditorFallback: React.FC = () => ( -

+
); @@ -670,7 +673,7 @@ export const SqlEditorView: React.FC = () => {
{canEditorDestinations && ( -
+
)} @@ -755,7 +758,10 @@ export const SqlEditorView: React.FC = () => { Runs -
+ {/* `shrink-0`: a segmented control has no slack to give. Letting it + shrink clips its second button behind `overflow-hidden`, which + leaves the button hit-testable but not clickable. */} +