Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/e2e/src/pages/LokeeHistoryPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,23 @@ export class LokeeHistoryPage {
}

async openComparePane(): Promise<void> {
// 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<void> {
// 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(
Expand All @@ -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 &&
Expand Down
22 changes: 21 additions & 1 deletion apps/e2e/src/pages/SqlEditorPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ export class SqlEditorPage {
}

async checkConnection(name: string): Promise<void> {
// 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);
Expand Down Expand Up @@ -359,6 +363,11 @@ export class SqlEditorPage {
async openTableBlueprint(tableName: string): Promise<void> {
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
Expand Down Expand Up @@ -455,8 +464,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();
Expand All @@ -465,6 +482,9 @@ export class SqlEditorPage {
/** Open Data Peek for a table from the Schema tree (modifier-click the row). */
async openDataPeek(tableName: string): Promise<void> {
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(
Expand Down
4 changes: 3 additions & 1 deletion apps/e2e/src/tests/dialects/shared-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,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();
});
}
3 changes: 3 additions & 0 deletions apps/e2e/src/tests/sql-editor-blueprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]');
Expand Down
3 changes: 3 additions & 0 deletions apps/e2e/src/tests/sql-editor-column-picker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions apps/e2e/src/tests/sql-editor-result-edit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
6 changes: 6 additions & 0 deletions apps/e2e/src/tests/sql-editor-sqlite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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"]');
Expand Down
3 changes: 3 additions & 0 deletions apps/e2e/src/tests/sql-editor-utilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]');
Expand Down
12 changes: 11 additions & 1 deletion apps/web/src/frontend/app/shell/ConnectionChips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`}
>
<span className={`shrink-0 text-[10px] font-bold uppercase tracking-wider ${tone.label}`}>
{label}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/frontend/app/shell/TopToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,8 @@ export const TopToolbar: React.FC = () => {
];

return (
<header data-testid="toolbar" className="border-b border-slate-800 bg-slate-900/90 backdrop-blur-md px-3 py-1.5 flex flex-col gap-1.5">
<div className="flex min-h-11 flex-wrap items-center gap-2">
<header data-testid="toolbar" className="border-b border-slate-800 bg-slate-900/90 backdrop-blur-md px-3 py-1 flex flex-col gap-1">
<div className="flex min-h-9 flex-wrap items-center gap-1.5">
{activeView === 'sync' && canSchemaBrowse && (
<div
data-testid="sync-pane-switcher"
Expand Down
68 changes: 67 additions & 1 deletion apps/web/src/frontend/app/store/useSqlEditorStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@ import { beamAliasesForCount, MAX_SERVERS } from '@foxschema/shared';
import { buildSampleBookmarks } from '@/features/sql-editor/lib/sqlEditorSamples';
import {
buildForeignKeyDrilldown,
buildInboundDrilldown,
buildOrphanPeek,
fkKey,
buildRowLookup,
buildTablePreview,
composePeekSql,
fkDrillTableName,
} from '@/shared/lib/tablePreview';
import type { InboundForeignKey } from '@/shared/lib/tablePreview';
import {
getSessionPassword,
sessionPasswordMap,
Expand Down Expand Up @@ -67,7 +69,11 @@ import {
import { connectionNeedsSecret } from '@/shared/lib/provider-settings';
import { useSyncStore } from './useSyncStore';
import type { SchemaCacheEntry } from '@/features/sql-editor/lib/sqlEditorBridge';
import { getCaretOffset, getSelectedSql } from '@/features/sql-editor/lib/sqlEditorBridge';
import {
getCaretOffset,
getSelectedSql,
setSqlInsertFallback,
} from '@/features/sql-editor/lib/sqlEditorBridge';
import {
addTab as addTabLogic,
checkedAfterSqlChange,
Expand Down Expand Up @@ -553,6 +559,12 @@ interface SqlEditorState {
fk: ForeignKeyInfo,
values: unknown[]
) => Promise<void>;
/** The other direction: rows that reference the row you are looking at. */
drillDataPeekInbound: (
fromEntryId: string,
child: InboundForeignKey,
values: unknown[]
) => Promise<void>;
closeDataPeek: () => void;
/** Drop `entryId` and every grid below it (click a breadcrumb to go back). */
closeDataPeekFrom: (entryId: string) => void;
Expand Down Expand Up @@ -1931,6 +1943,45 @@ export const useSqlEditorStore = create<SqlEditorState>()(
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;
Expand Down Expand Up @@ -2569,3 +2620,18 @@ export const useSqlEditorStore = create<SqlEditorState>()(
}
)
);

/**
* 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);
});
Loading
Loading