diff --git a/apps/cli/src/commands/drivers.ts b/apps/cli/src/commands/drivers.ts index 379698f..f481e86 100644 --- a/apps/cli/src/commands/drivers.ts +++ b/apps/cli/src/commands/drivers.ts @@ -84,11 +84,18 @@ export async function runDriversInstall(name: string): Promise { const cwd = webWorkspaceRoot(); // Prefer installing into the @foxschema/web package directory. + // ibm_db must run install scripts (--foreground-scripts) or clidriver never + // downloads and Windows connects fail with SQL1042C / missing native binding. + const npmArgs = + entry.pkg === 'ibm_db' + ? ['install', 'ibm_db@4.0.1', '--foreground-scripts', '--prefix', cwd] + : ['install', entry.pkg, '--foreground-scripts', '--prefix', cwd]; + await new Promise((resolve, reject) => { - const child = spawn('npm', ['install', entry.pkg, '--prefix', cwd], { + const child = spawn('npm', npmArgs, { stdio: 'inherit', shell: process.platform === 'win32', - env: process.env, + env: { ...process.env, npm_config_ignore_scripts: '' }, }); child.on('error', reject); child.on('exit', (code) => { diff --git a/apps/e2e/src/pages/SqlEditorPage.ts b/apps/e2e/src/pages/SqlEditorPage.ts index 0d99292..ec45f27 100644 --- a/apps/e2e/src/pages/SqlEditorPage.ts +++ b/apps/e2e/src/pages/SqlEditorPage.ts @@ -192,18 +192,23 @@ export class SqlEditorPage { tableName, { timeout: 45_000 } ); - // Prefer the blueprint button nearest the table name label. + // Edit table sits under the name (always visible — no hover / group class). const nameLabel = explorer.getByText(tableName, { exact: true }).first(); await nameLabel.scrollIntoViewIfNeeded(); - const row = nameLabel.locator('xpath=ancestor::div[contains(@class,"group")][1]'); - await row.hover(); + const row = nameLabel.locator('xpath=ancestor::div[./button[@data-testid="sql-open-blueprint"] or .//button[@data-testid="sql-open-blueprint"]][1]'); await row.locator('[data-testid="sql-open-blueprint"]').click({ force: true }); await waitFor(this.page, '[data-testid="table-blueprint-modal"]', 15_000); } async openNewTableBlueprint(): Promise { await this.dismissOverlays(); - await clickWhen(this.page, '[data-testid="sql-new-table"]'); + // Prefer Schema header action; fall back to explorer New control. + const header = this.page.locator('[data-testid="sql-schema-new-table"]'); + if (await header.count()) { + await header.click(); + } else { + await clickWhen(this.page, '[data-testid="sql-new-table"]'); + } await waitFor(this.page, '[data-testid="table-blueprint-modal"]', 15_000); } diff --git a/apps/e2e/src/tests/sql-editor-blueprint.test.ts b/apps/e2e/src/tests/sql-editor-blueprint.test.ts index 119a4a9..6ddac0d 100644 --- a/apps/e2e/src/tests/sql-editor-blueprint.test.ts +++ b/apps/e2e/src/tests/sql-editor-blueprint.test.ts @@ -146,6 +146,56 @@ describe.skipIf(!ready)('SQL Editor · blueprint + paging (SQLite)', () => { expect(editorText.toLowerCase()).toMatch(/create\s*table/); }); + it('Indexes section can add a UNIQUE index on a column', async () => { + await sql.openNewTableBlueprint(); + const modal = driver.locator('[data-testid="table-blueprint-modal"]'); + expect(await modal.isVisible()).toBe(true); + await driver.locator('[data-testid="blueprint-table-name"]').fill(`t_idx_${RUN}`); + + await modal.getByRole('button', { name: /add column/i }).click(); + await waitForColumnForm(driver); + await modal.locator('[data-testid="blueprint-column-form"] input').first().fill('email'); + await modal.getByRole('button', { name: /^save$/i }).click(); + await driver.waitForFunction(() => { + const m = document.querySelector('[data-testid="table-blueprint-modal"]'); + return !!m && /email/i.test(m.textContent ?? '') && !m.querySelector('[data-testid="blueprint-column-form"]'); + }); + + const indexes = modal.locator('[data-testid="blueprint-indexes"]'); + expect(await indexes.isVisible()).toBe(true); + await indexes.locator('[data-testid="blueprint-add-index"]').click(); + await driver.waitForSelector('[data-testid="blueprint-index-form"]', { timeout: 5_000 }); + + // openAddIndex already selects the first column — only toggle email on if missing. + const form = modal.locator('[data-testid="blueprint-index-form"]'); + const formText = await form.innerText(); + if (!/\(email/i.test(formText)) { + await form.getByRole('button', { name: /^email$/i }).click(); + } + const unique = form.locator('input[type="checkbox"]').first(); + if (await unique.count()) { + if (!(await unique.isChecked())) await unique.check(); + } + await modal.locator('[data-testid="blueprint-index-save"]').click(); + + await driver.waitForFunction(() => { + const root = document.querySelector('[data-testid="blueprint-indexes"]'); + if (!root) return false; + if (root.querySelector('[data-testid="blueprint-index-form"]')) return false; + return /email/i.test(root.textContent ?? ''); + }, { timeout: 5_000 }); + + await sql.blueprintInsertSql(); + await sql.closeBlueprint().catch(() => undefined); + await driver.waitForTimeout(500); + const editorText = await driver.evaluate(() => { + const lines = document.querySelector('.monaco-editor .view-lines'); + const ta = document.querySelector('.monaco-editor textarea') as HTMLTextAreaElement | null; + return `${lines?.textContent ?? ''}\n${ta?.value ?? ''}`; + }); + expect(editorText.toLowerCase()).toMatch(/create\s+unique\s+index|create\s+index/); + }); + it('pages SELECT results with Next/Prev', async () => { await driver.locator('[data-testid="sql-max-rows"]').fill('2'); await sql.setSql('SELECT id1, id2, label FROM parent ORDER BY id1;'); diff --git a/apps/web/src/backend/api/routes.ts b/apps/web/src/backend/api/routes.ts index 4da1dd5..dd56ae3 100644 --- a/apps/web/src/backend/api/routes.ts +++ b/apps/web/src/backend/api/routes.ts @@ -10,6 +10,7 @@ import { DriverDetector, buildConnectionString, normalizeTableSchemas, + getProviderSettings, type MigrationStep, type ConnectionOptions, type DbObjectType, @@ -358,6 +359,13 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt const { scope, ...ref } = req.body as ConnectionRef & { scope: DbObjectType[] }; try { const { dialect, option, schema } = await resolveRef((req as AuthedRequest).userId, ref); + const settings = getProviderSettings(dialect); + if (settings.schemaRequired && !schema?.trim()) { + res.status(400).json({ + error: `${settings.label} requires a schema. Load schemas for the connection, then pick one before browsing or editing tables.`, + }); + return; + } const { tables, warnings } = await loadScopedTables(dialect, option, schema, scope); res.json(warnings.length ? { tables, warnings } : { tables }); } catch (error: unknown) { diff --git a/apps/web/src/frontend/App.tsx b/apps/web/src/frontend/App.tsx index 5ba4859..cc935a9 100644 --- a/apps/web/src/frontend/App.tsx +++ b/apps/web/src/frontend/App.tsx @@ -74,14 +74,37 @@ const App: React.FC = () => { init(); }, [init, apply]); - // Once signed in, load the user's saved connections and appearance + // Once signed in, load the user's saved connections and appearance. + // Wait for sync-store persist rehydrate so selected connection IDs are + // restored before loadConnections reapplies source/target configs. useEffect(() => { - if (status === 'ready') { - useSyncStore.getState().loadConnections(); + if (status !== 'ready') return; + + let cancelled = false; + const load = () => { + if (cancelled) return; + void useSyncStore.getState().loadConnections(); apiGetPreferences() - .then((p) => hydrateFromServer(p.theme)) + .then((p) => { + if (!cancelled) hydrateFromServer(p.theme); + }) .catch(() => undefined); + }; + + if (useSyncStore.persist.hasHydrated()) { + load(); + return () => { + cancelled = true; + }; } + + const unsub = useSyncStore.persist.onFinishHydration(() => { + load(); + }); + return () => { + cancelled = true; + unsub(); + }; }, [status, hydrateFromServer]); if (status === 'loading') { diff --git a/apps/web/src/frontend/components/sql-editor/ConnectionChecklist.tsx b/apps/web/src/frontend/components/sql-editor/ConnectionChecklist.tsx index 0f71fcb..c852cc8 100644 --- a/apps/web/src/frontend/components/sql-editor/ConnectionChecklist.tsx +++ b/apps/web/src/frontend/components/sql-editor/ConnectionChecklist.tsx @@ -12,6 +12,7 @@ import { SQL_ICON_STROKE } from './sqlIconStyle'; */ export const ConnectionChecklist: React.FC = () => { const connections = useSyncStore((s) => s.connections); + const connectionsLoaded = useSyncStore((s) => s.connectionsLoaded); const tabs = useSqlEditorStore((s) => s.tabs); const activeTabId = useSqlEditorStore((s) => s.activeTabId); const shareDestinations = useSqlEditorStore((s) => s.shareDestinations); @@ -59,7 +60,9 @@ export const ConnectionChecklist: React.FC = () => { - {connections.length === 0 ? ( + {!connectionsLoaded ? ( +

Loading connections…

+ ) : connections.length === 0 ? (

No saved connections yet — add one via the Credentials button in the toolbar.

diff --git a/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx b/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx index e7b85f9..465db50 100644 --- a/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx +++ b/apps/web/src/frontend/components/sql-editor/SqlEditorView.tsx @@ -16,6 +16,7 @@ import { KeyRound, PanelLeftClose, PanelLeftOpen, + Plus, } from 'lucide-react'; import { useSyncStore } from '../../store/useSyncStore'; import { useSqlEditorStore } from '../../store/useSqlEditorStore'; @@ -31,7 +32,7 @@ import { SqlBookmarksPanel } from './SqlBookmarksPanel'; import { SqlVariablesPanel } from './SqlVariablesPanel'; import { SqlSecretsPanel, type SqlSecretsPanelHandle } from './SqlSecretsPanel'; import { SQL_ICON_STROKE } from './sqlIconStyle'; -import { SqlSchemaExplorer } from './SqlSchemaExplorer'; +import { SqlSchemaExplorer, type SqlSchemaExplorerHandle } from './SqlSchemaExplorer'; import { SqlSidebarSection, useSidebarSectionHeights, @@ -129,6 +130,7 @@ export const SqlEditorView: React.FC = () => { const [sidebarOpen, toggleSidebar] = useSidebarSectionsOpen(); const [sectionHeights, setSectionHeight] = useSidebarSectionHeights(); const secretsPanelRef = useRef(null); + const schemaExplorerRef = useRef(null); const [secretsRefreshing, setSecretsRefreshing] = useState(false); const onSecretsRefresh = useCallback(async () => { @@ -377,8 +379,20 @@ export const SqlEditorView: React.FC = () => { grow height={sectionHeights.schema} onResizeHeight={(h) => setSectionHeight('schema', h)} + actions={ + + } > - +
void; +} + /** Categories shown in the SQL Editor schema browser (order = display order). */ const EXPLORER_GROUPS: { type: DbObjectType; title: string }[] = [ { type: 'TABLE', title: 'Tables' }, @@ -41,8 +47,12 @@ function writeStoredExplorerId(id: string): void { * Slim schema tree for the SQL Editor. Categorized TABLE / VIEW / MQT / * PROCEDURE / FUNCTION — click a name to insert at the Monaco cursor. */ -export const SqlSchemaExplorer: React.FC = () => { +export const SqlSchemaExplorer = forwardRef(function SqlSchemaExplorer( + _props, + ref +) { const connections = useSyncStore((s) => s.connections); + const connectionsLoaded = useSyncStore((s) => s.connectionsLoaded); const tabs = useSqlEditorStore((s) => s.tabs); const activeTabId = useSqlEditorStore((s) => s.activeTabId); const schemaCache = useSqlEditorStore((s) => s.schemaCache); @@ -51,8 +61,12 @@ export const SqlSchemaExplorer: React.FC = () => { const sharedConnectionIds = useSqlEditorStore((s) => s.sharedConnectionIds); const tab = tabs.find((t) => t.id === activeTabId) ?? tabs[0]!; - const preferredIds = effectiveConnectionIds(tab, shareDestinations, sharedConnectionIds).filter( - (id) => connections.some((c) => c.id === id) + const preferredIds = useMemo( + () => + effectiveConnectionIds(tab, shareDestinations, sharedConnectionIds).filter((id) => + connections.some((c) => c.id === id) + ), + [tab, shareDestinations, sharedConnectionIds, connections] ); const [explorerId, setExplorerId] = useState(() => readStoredExplorerId()); @@ -72,7 +86,18 @@ export const SqlSchemaExplorer: React.FC = () => { writeStoredExplorerId(id); }; + const openCreateTable = () => { + setBlueprintMode('create'); + setBlueprintTable(null); + }; + + useImperativeHandle(ref, () => ({ openCreateTable }), []); + useEffect(() => { + // After refresh, connections starts [] until loadConnections finishes. + // Do not clear a persisted explorerId during that empty-list window. + if (connections.length === 0) return; + if (explorerId && connections.some((c) => c.id === explorerId)) { // Keep a valid selection in storage (e.g. after first hydrate). writeStoredExplorerId(explorerId); @@ -114,10 +139,23 @@ export const SqlSchemaExplorer: React.FC = () => { ); const conn = connections.find((c) => c.id === explorerId); + let schemaMissingHint: string | null = null; + if (conn) { + try { + const settings = getProviderSettings(conn.dialect); + if (settings.schemaRequired && !conn.schema?.trim()) { + schemaMissingHint = `${settings.label} needs a schema on the connection. Edit it under Credentials, pick a schema, then reload.`; + } + } catch { + /* unknown dialect */ + } + } return (
- {connections.length === 0 ? ( + {!connectionsLoaded ? ( +

Loading connections…

+ ) : connections.length === 0 ? (

Save a connection to browse its tables.

) : ( <> @@ -136,16 +174,14 @@ export const SqlSchemaExplorer: React.FC = () => {
+ {schemaMissingHint && ( +

+ {schemaMissingHint} +

+ )} {entry?.status === 'error' && (

{entry.error}

)} @@ -170,10 +211,22 @@ export const SqlSchemaExplorer: React.FC = () => { Loading…

)} - {entry?.status === 'ready' && totalCount === 0 && ( -

- No tables, views, procedures, or functions in this schema. -

+ {entry?.status === 'ready' && totalCount === 0 && !schemaMissingHint && ( +
+

+ No tables in this schema yet. Use New to open the table blueprint and add columns. +

+ +
)}
@@ -250,7 +303,7 @@ export const SqlSchemaExplorer: React.FC = () => { )}
); -}; +}); const ObjectNode: React.FC<{ table: TableSchema; @@ -280,12 +333,12 @@ const ObjectNode: React.FC<{ }; return ( -
-
+
+
- {onOpenBlueprint && ( - - )}
+ {onOpenBlueprint && ( + + )} {open && isRoutine && params.length === 0 && (

No parameters

)} diff --git a/apps/web/src/frontend/components/sql-editor/TableBlueprintModal.tsx b/apps/web/src/frontend/components/sql-editor/TableBlueprintModal.tsx index d049d3e..56bae68 100644 --- a/apps/web/src/frontend/components/sql-editor/TableBlueprintModal.tsx +++ b/apps/web/src/frontend/components/sql-editor/TableBlueprintModal.tsx @@ -1133,7 +1133,7 @@ export const TableBlueprintModal: React.FC = ({ {/* Indexes */} -
+

@@ -1145,6 +1145,7 @@ export const TableBlueprintModal: React.FC = ({ {!indexFormOpen && indexSupport.create && (