mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-16 08:25:14 +02:00
feat(cli): tree-picker UI for database scope selection (#81)
* refactor(cli): extract generic tree picker from Notion-specific modules Rename notion-page-picker-tree → tree-picker-state and notion-page-picker-tui → tree-picker-tui, removing Notion-specific naming so the tree picker can be reused for database scope selection. Update notion-page-picker to consume the new generic interfaces. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(cli): add database tree picker for schema and table scope selection Replace inline multiselect prompts in setup-databases with a new database-tree-picker that uses the generic tree picker TUI. This gives database scope selection the same grouped tree UI as the Notion page picker, combining schema and table selection into a single step. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c2750dd797
commit
dabd640cad
11 changed files with 1299 additions and 834 deletions
|
|
@ -5,10 +5,15 @@ import { initKtxProject, parseKtxProjectConfig, readKtxSetupState, writeKtxSetup
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
type KtxSetupDatabaseDriver,
|
||||
type KtxSetupDatabasesDeps,
|
||||
type KtxSetupDatabasesPromptAdapter,
|
||||
runKtxSetupDatabasesStep,
|
||||
} from './setup-databases.js';
|
||||
import type { KtxCliIo } from './cli-runtime.js';
|
||||
import type {
|
||||
DatabaseScopePickResult,
|
||||
PickDatabaseScopeArgs,
|
||||
} from './database-tree-picker.js';
|
||||
|
||||
function makeIo() {
|
||||
let stdout = '';
|
||||
|
|
@ -32,6 +37,43 @@ function makeIo() {
|
|||
};
|
||||
}
|
||||
|
||||
type ScopePick =
|
||||
| 'back'
|
||||
| 'enable-all'
|
||||
| { schemas: string[]; tables: string[] };
|
||||
|
||||
interface PickerStubs {
|
||||
pickDatabaseScope: KtxSetupDatabasesDeps['pickDatabaseScope'];
|
||||
scopeCalls: PickDatabaseScopeArgs[];
|
||||
}
|
||||
|
||||
function makePickerStubs(options: { scopes?: ScopePick[] } = {}): PickerStubs {
|
||||
const queue: ScopePick[] = [...(options.scopes ?? [])];
|
||||
const scopeCalls: PickDatabaseScopeArgs[] = [];
|
||||
return {
|
||||
scopeCalls,
|
||||
pickDatabaseScope: vi.fn(async (args: PickDatabaseScopeArgs): Promise<DatabaseScopePickResult> => {
|
||||
scopeCalls.push(args);
|
||||
const next = queue.shift();
|
||||
if (next === undefined || next === 'enable-all') {
|
||||
const enabledTables = args.discovered.map((t) => `${t.schema}.${t.name}`);
|
||||
const activeSchemas = args.supportsSchemaScope
|
||||
? Array.from(new Set(args.discovered.map((t) => t.schema)))
|
||||
: [];
|
||||
return { kind: 'selected', activeSchemas, enabledTables };
|
||||
}
|
||||
if (next === 'back') {
|
||||
return { kind: 'back' };
|
||||
}
|
||||
return {
|
||||
kind: 'selected',
|
||||
activeSchemas: args.supportsSchemaScope ? next.schemas : [],
|
||||
enabledTables: next.tables,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function makePromptAdapter(options: {
|
||||
multiselectValues?: string[][];
|
||||
selectValues?: string[];
|
||||
|
|
@ -819,7 +861,6 @@ describe('setup databases step', () => {
|
|||
await writeKtxSetupState(tempDir, { completed_steps: ['databases'] });
|
||||
const prompts = makePromptAdapter({
|
||||
textValues: ['env:DATABASE_URL'],
|
||||
multiselectValues: [['analytics']],
|
||||
});
|
||||
let primaryMenuCount = 0;
|
||||
vi.mocked(prompts.select).mockImplementation(async (options) => {
|
||||
|
|
@ -835,11 +876,21 @@ describe('setup databases step', () => {
|
|||
const scanConnection = vi.fn(async () => 0);
|
||||
const listSchemas = vi.fn(async () => ['analytics', 'public']);
|
||||
const listTables = vi.fn(async () => [{ schema: 'analytics', name: 'customers', kind: 'table' as const }]);
|
||||
const pickers = makePickerStubs({
|
||||
scopes: [{ schemas: ['analytics'], tables: ['analytics.customers'] }],
|
||||
});
|
||||
|
||||
const result = await runKtxSetupDatabasesStep(
|
||||
{ projectDir: tempDir, inputMode: 'auto', skipDatabases: false, databaseSchemas: [] },
|
||||
makeIo().io,
|
||||
{ prompts, testConnection, scanConnection, listSchemas, listTables },
|
||||
{
|
||||
prompts,
|
||||
testConnection,
|
||||
scanConnection,
|
||||
listSchemas,
|
||||
listTables,
|
||||
pickDatabaseScope: pickers.pickDatabaseScope,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: 'ready', projectDir: tempDir, connectionIds: ['warehouse'] });
|
||||
|
|
@ -848,7 +899,7 @@ describe('setup databases step', () => {
|
|||
placeholder: 'env:DATABASE_URL',
|
||||
initialValue: 'env:DATABASE_URL',
|
||||
});
|
||||
expect(listTables).toHaveBeenCalledWith(tempDir, 'warehouse');
|
||||
expect(listTables).toHaveBeenCalledWith(tempDir, 'warehouse', ['analytics', 'public']);
|
||||
expect(testConnection).toHaveBeenCalledWith(tempDir, 'warehouse', expect.anything());
|
||||
expect(scanConnection).toHaveBeenCalledWith(tempDir, 'warehouse', expect.anything());
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
|
|
@ -882,7 +933,6 @@ describe('setup databases step', () => {
|
|||
await writeKtxSetupState(tempDir, { completed_steps: ['databases'] });
|
||||
const prompts = makePromptAdapter({
|
||||
textValues: ['env:DATABASE_URL'],
|
||||
multiselectValues: [['public'], ['public.customers', 'public.orders']],
|
||||
});
|
||||
let primaryMenuCount = 0;
|
||||
vi.mocked(prompts.select).mockImplementation(async (options) => {
|
||||
|
|
@ -892,7 +942,6 @@ describe('setup databases step', () => {
|
|||
}
|
||||
if (options.message === 'Primary source to edit') return 'warehouse';
|
||||
if (options.message === 'How do you want to connect to PostgreSQL?') return 'url';
|
||||
if (options.message.startsWith('Tables found in selected schemas')) return 'customize';
|
||||
return 'back';
|
||||
});
|
||||
const listSchemas = vi.fn(async () => ['orbit_analytics', 'orbit_raw', 'public']);
|
||||
|
|
@ -901,6 +950,9 @@ describe('setup databases step', () => {
|
|||
{ schema: 'public', name: 'orders', kind: 'table' as const },
|
||||
{ schema: 'public', name: 'products', kind: 'table' as const },
|
||||
]);
|
||||
const pickers = makePickerStubs({
|
||||
scopes: [{ schemas: ['public'], tables: ['public.customers', 'public.orders'] }],
|
||||
});
|
||||
|
||||
const result = await runKtxSetupDatabasesStep(
|
||||
{ projectDir: tempDir, inputMode: 'auto', skipDatabases: false, databaseSchemas: [] },
|
||||
|
|
@ -911,29 +963,17 @@ describe('setup databases step', () => {
|
|||
scanConnection: vi.fn(async () => 0),
|
||||
listSchemas,
|
||||
listTables,
|
||||
pickDatabaseScope: pickers.pickDatabaseScope,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: 'ready', projectDir: tempDir, connectionIds: ['warehouse'] });
|
||||
expect(prompts.multiselect).toHaveBeenNthCalledWith(1, {
|
||||
message: expect.stringContaining('PostgreSQL schemas to scan'),
|
||||
options: [
|
||||
{ value: 'orbit_analytics', label: 'orbit_analytics' },
|
||||
{ value: 'orbit_raw', label: 'orbit_raw' },
|
||||
{ value: 'public', label: 'public' },
|
||||
],
|
||||
initialValues: ['public'],
|
||||
required: true,
|
||||
});
|
||||
expect(prompts.multiselect).toHaveBeenNthCalledWith(2, {
|
||||
message: expect.stringContaining('Tables to enable for warehouse'),
|
||||
options: [
|
||||
{ value: 'public.customers', label: 'public.customers' },
|
||||
{ value: 'public.orders', label: 'public.orders' },
|
||||
{ value: 'public.products', label: 'public.products' },
|
||||
],
|
||||
initialValues: ['public.customers', 'public.orders'],
|
||||
required: true,
|
||||
expect(pickers.scopeCalls).toHaveLength(1);
|
||||
expect(pickers.scopeCalls[0]).toMatchObject({
|
||||
connectionId: 'warehouse',
|
||||
schemaNoun: 'schema',
|
||||
supportsSchemaScope: true,
|
||||
existing: { enabledTables: ['public.customers', 'public.orders'] },
|
||||
});
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.connections.warehouse).toMatchObject({
|
||||
|
|
@ -965,7 +1005,6 @@ describe('setup databases step', () => {
|
|||
await writeKtxSetupState(tempDir, { completed_steps: ['databases'] });
|
||||
const prompts = makePromptAdapter({
|
||||
textValues: ['env:DATABASE_URL'],
|
||||
multiselectValues: [['back']],
|
||||
});
|
||||
let primaryMenuCount = 0;
|
||||
vi.mocked(prompts.select).mockImplementation(async (options) => {
|
||||
|
|
@ -980,19 +1019,29 @@ describe('setup databases step', () => {
|
|||
const testConnection = vi.fn(async () => 0);
|
||||
const scanConnection = vi.fn(async () => 0);
|
||||
const listSchemas = vi.fn(async () => ['analytics', 'public']);
|
||||
const listTables = vi.fn(async () => [{ schema: 'analytics', name: 'customers', kind: 'table' as const }]);
|
||||
const listTables = vi.fn(async () => [
|
||||
{ schema: 'analytics', name: 'customers', kind: 'table' as const },
|
||||
{ schema: 'public', name: 'orders', kind: 'table' as const },
|
||||
]);
|
||||
const pickers = makePickerStubs({ scopes: ['back'] });
|
||||
|
||||
const result = await runKtxSetupDatabasesStep(
|
||||
{ projectDir: tempDir, inputMode: 'auto', skipDatabases: false, databaseSchemas: [] },
|
||||
makeIo().io,
|
||||
{ prompts, testConnection, scanConnection, listSchemas, listTables },
|
||||
{
|
||||
prompts,
|
||||
testConnection,
|
||||
scanConnection,
|
||||
listSchemas,
|
||||
listTables,
|
||||
pickDatabaseScope: pickers.pickDatabaseScope,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: 'ready', projectDir: tempDir, connectionIds: ['warehouse'] });
|
||||
expect(primaryMenuCount).toBe(2);
|
||||
expect(testConnection).toHaveBeenCalledWith(tempDir, 'warehouse', expect.anything());
|
||||
expect(scanConnection).not.toHaveBeenCalled();
|
||||
expect(listTables).not.toHaveBeenCalled();
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.connections.warehouse).toMatchObject({
|
||||
url: 'env:DATABASE_URL',
|
||||
|
|
@ -1031,7 +1080,6 @@ describe('setup databases step', () => {
|
|||
}
|
||||
if (options.message === 'Primary source to edit') return 'warehouse';
|
||||
if (options.message === 'How do you want to connect to PostgreSQL?') return 'url';
|
||||
if (options.message.startsWith('Tables found in selected schemas')) return 'back';
|
||||
return 'back';
|
||||
});
|
||||
const testConnection = vi.fn(async () => 0);
|
||||
|
|
@ -1041,16 +1089,24 @@ describe('setup databases step', () => {
|
|||
{ schema: 'public', name: 'customers', kind: 'table' as const },
|
||||
{ schema: 'public', name: 'orders', kind: 'table' as const },
|
||||
]);
|
||||
const pickers = makePickerStubs({ scopes: ['back'] });
|
||||
|
||||
const result = await runKtxSetupDatabasesStep(
|
||||
{ projectDir: tempDir, inputMode: 'auto', skipDatabases: false, databaseSchemas: [] },
|
||||
makeIo().io,
|
||||
{ prompts, testConnection, scanConnection, listSchemas, listTables },
|
||||
{
|
||||
prompts,
|
||||
testConnection,
|
||||
scanConnection,
|
||||
listSchemas,
|
||||
listTables,
|
||||
pickDatabaseScope: pickers.pickDatabaseScope,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: 'ready', projectDir: tempDir, connectionIds: ['warehouse'] });
|
||||
expect(primaryMenuCount).toBe(2);
|
||||
expect(listTables).toHaveBeenCalledWith(tempDir, 'warehouse');
|
||||
expect(listTables).toHaveBeenCalledWith(tempDir, 'warehouse', ['public']);
|
||||
expect(scanConnection).not.toHaveBeenCalled();
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.connections.warehouse).toMatchObject({
|
||||
|
|
@ -1083,19 +1139,18 @@ describe('setup databases step', () => {
|
|||
await writeKtxSetupState(tempDir, { completed_steps: ['databases'] });
|
||||
const prompts = makePromptAdapter({
|
||||
textValues: ['env:DATABASE_URL'],
|
||||
multiselectValues: [['public']],
|
||||
});
|
||||
vi.mocked(prompts.select).mockImplementation(async (options) => {
|
||||
if (options.message === 'Primary sources already configured: warehouse\nWhat would you like to do?') return 'edit';
|
||||
if (options.message === 'Primary source to edit') return 'warehouse';
|
||||
if (options.message === 'How do you want to connect to PostgreSQL?') return 'url';
|
||||
if (options.message.startsWith('Tables found in selected schemas')) return 'all';
|
||||
return 'back';
|
||||
});
|
||||
const listTables = vi.fn(async () => [
|
||||
{ schema: 'public', name: 'customers', kind: 'table' as const },
|
||||
{ schema: 'public', name: 'orders', kind: 'table' as const },
|
||||
]);
|
||||
const pickers = makePickerStubs({ scopes: ['enable-all'] });
|
||||
|
||||
const result = await runKtxSetupDatabasesStep(
|
||||
{ projectDir: tempDir, inputMode: 'auto', skipDatabases: false, databaseSchemas: [] },
|
||||
|
|
@ -1105,6 +1160,7 @@ describe('setup databases step', () => {
|
|||
testConnection: vi.fn(async () => 0),
|
||||
scanConnection: vi.fn(async () => 1),
|
||||
listTables,
|
||||
pickDatabaseScope: pickers.pickDatabaseScope,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -1390,7 +1446,6 @@ describe('setup databases step', () => {
|
|||
const prompts = makePromptAdapter({
|
||||
selectValues: ['url'],
|
||||
textValues: ['', 'env:DATABASE_URL'],
|
||||
multiselectValues: [['orbit_analytics', 'orbit_raw']],
|
||||
});
|
||||
const testConnection = vi.fn(async () => 0);
|
||||
const scanConnection = vi.fn(async asyncScanProjectDir => {
|
||||
|
|
@ -1401,6 +1456,19 @@ describe('setup databases step', () => {
|
|||
return 0;
|
||||
});
|
||||
const listSchemas = vi.fn(async () => ['orbit_analytics', 'orbit_raw', 'public']);
|
||||
const listTables = vi.fn(async () => [
|
||||
{ schema: 'orbit_analytics', name: 'events', kind: 'table' as const },
|
||||
{ schema: 'orbit_raw', name: 'inputs', kind: 'table' as const },
|
||||
{ schema: 'public', name: 'misc', kind: 'table' as const },
|
||||
]);
|
||||
const pickers = makePickerStubs({
|
||||
scopes: [
|
||||
{
|
||||
schemas: ['orbit_analytics', 'orbit_raw'],
|
||||
tables: ['orbit_analytics.events', 'orbit_raw.inputs'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await runKtxSetupDatabasesStep(
|
||||
{
|
||||
|
|
@ -1411,20 +1479,24 @@ describe('setup databases step', () => {
|
|||
skipDatabases: false,
|
||||
},
|
||||
io.io,
|
||||
{ prompts, testConnection, scanConnection, listSchemas },
|
||||
{
|
||||
prompts,
|
||||
testConnection,
|
||||
scanConnection,
|
||||
listSchemas,
|
||||
listTables,
|
||||
pickDatabaseScope: pickers.pickDatabaseScope,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe('ready');
|
||||
expect(listSchemas).toHaveBeenCalledWith(tempDir, 'postgres-warehouse');
|
||||
expect(prompts.multiselect).toHaveBeenCalledWith({
|
||||
message: expect.stringContaining('PostgreSQL schemas to scan'),
|
||||
options: [
|
||||
{ value: 'orbit_analytics', label: 'orbit_analytics' },
|
||||
{ value: 'orbit_raw', label: 'orbit_raw' },
|
||||
{ value: 'public', label: 'public' },
|
||||
],
|
||||
initialValues: ['orbit_analytics', 'orbit_raw'],
|
||||
required: true,
|
||||
expect(pickers.scopeCalls).toHaveLength(1);
|
||||
expect(pickers.scopeCalls[0]).toMatchObject({
|
||||
connectionId: 'postgres-warehouse',
|
||||
schemaNoun: 'schema',
|
||||
schemaNounPlural: 'schemas',
|
||||
defaultSchemas: ['orbit_analytics', 'orbit_raw'],
|
||||
});
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.connections['postgres-warehouse']).toMatchObject({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue