Skip to content

Commit 1d02276

Browse files
JasonWarrenUKclaude
andcommitted
feat(tui): rebuild workflow screen on app shell + panel primitives
Brings the convert/validate/check processing screen onto the same appShell()/panel()/Keymap frame adopted by the dashboard (TR.B1) and file-picker (TR.B2), replacing its hand-rolled root container and ad-hoc keypress listener. Sets up TR.C3 (progress bar + elapsed time). Behaviour is unchanged: the screen still runs its workflow generator to completion and replace-routes to the result screen, with the schema-load error path now driven through the keymap registry instead of a manual keyInput listener. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 5f23f60 commit 1d02276

2 files changed

Lines changed: 198 additions & 40 deletions

File tree

src/tui/screens/workflow.ts

Lines changed: 21 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
/** ====== Workflow Screen ======
22
* Generic workflow runner that handles convert, validate, and check flows
33
*/
4-
import { BoxRenderable, TextRenderable, type KeyEvent } from '@opentui/core';
4+
import { BoxRenderable, TextRenderable } from '@opentui/core';
55
import { SpinnerRenderable } from 'opentui-spinner';
66
import type { RenderContext, Renderer } from '../types';
77
import { theme, symbols } from '../../../assets/brand/theme';
88
import type { Screen, ScreenResult, ScreenData } from '../utils/router';
9+
import { appShell, panel, type AppShell, type Panel } from '../components';
10+
import { Keymap } from '../utils/keymap';
911
import { buildSchemaRegistry } from '../../lib/schema/registryBuilder';
1012
import { convertWorkflow } from '../../lib/workflows/csvConvert';
1113
import { validateWorkflow } from '../../lib/workflows/csvValidate';
@@ -85,10 +87,10 @@ const WORKFLOW_CONFIGS: Record<WorkflowType, WorkflowConfig> = {
8587
export class WorkflowScreen implements Screen {
8688
readonly name = 'workflow';
8789
private renderer: Renderer;
88-
private container?: BoxRenderable;
90+
private shell?: AppShell;
91+
private stepsPanel?: Panel;
8992
private stepsContainer?: BoxRenderable;
90-
private statusBar?: TextRenderable;
91-
private keyHandler?: (key: KeyEvent) => void;
93+
private keymap?: Keymap;
9294

9395
private workflowType: WorkflowType = 'convert';
9496
private steps: StepDisplay[] = [];
@@ -154,9 +156,7 @@ export class WorkflowScreen implements Screen {
154156
}
155157

156158
cleanup(): void {
157-
if (this.keyHandler) {
158-
this.renderer.keyInput.off('keypress', this.keyHandler);
159-
}
159+
this.keymap?.detach(this.renderer);
160160
// Stop any running spinners
161161
for (const renderables of this.stepRenderables.values()) {
162162
if (renderables.spinner) {
@@ -315,24 +315,13 @@ export class WorkflowScreen implements Screen {
315315
}
316316

317317
private buildUI(title: string): void {
318-
// Root container
319-
this.container = new BoxRenderable(this.renderer, {
318+
this.shell = appShell(this.renderer, {
320319
id: CONTAINER_ID,
321-
flexDirection: 'column',
322-
width: '100%',
323-
height: '100%',
324-
backgroundColor: theme.background,
320+
breadcrumb: title,
321+
footer: 'Processing...',
325322
});
326323

327-
// Title
328-
const titleText = new TextRenderable(this.renderer, {
329-
content: title,
330-
fg: theme.primary,
331-
});
332-
this.container.add(titleText);
333-
334-
// Spacer
335-
this.container.add(new TextRenderable(this.renderer, { content: '' }));
324+
this.stepsPanel = panel(this.renderer, { title, flexGrow: 1 });
336325

337326
// Steps container
338327
this.stepsContainer = new BoxRenderable(this.renderer, {
@@ -376,17 +365,11 @@ export class WorkflowScreen implements Screen {
376365
});
377366
}
378367

379-
this.container.add(this.stepsContainer);
380-
381-
// Status bar
382-
this.statusBar = new TextRenderable(this.renderer, {
383-
content: 'Processing...',
384-
fg: theme.textMuted,
385-
});
386-
this.container.add(this.statusBar);
368+
this.stepsPanel.add(this.stepsContainer);
369+
this.shell.content.add(this.stepsPanel.box);
387370

388371
// Add to renderer
389-
this.renderer.root.add(this.container);
372+
this.renderer.root.add(this.shell.root);
390373
}
391374

392375
private handleEvent(event: WorkflowStepEvent): void {
@@ -519,16 +502,14 @@ export class WorkflowScreen implements Screen {
519502
}
520503

521504
private waitForKeyThenReplace(): Promise<ScreenResult> {
522-
if (this.statusBar) {
523-
this.statusBar.content = '[Any key] Continue';
524-
}
525505
return new Promise((resolve) => {
526-
this.keyHandler = () =>
527-
resolve({
528-
action: 'replace',
529-
screen: 'dashboard',
530-
});
531-
this.renderer.keyInput.once('keypress', this.keyHandler);
506+
const finish = () => resolve({ action: 'replace', screen: 'dashboard' });
507+
this.keymap = new Keymap({
508+
bindings: [{ keys: ['enter'], label: 'Continue', handler: finish }],
509+
onQuit: finish,
510+
});
511+
this.shell?.setFooter(this.keymap.toKeybar());
512+
this.keymap.attach(this.renderer);
532513
});
533514
}
534515

tests/tui/screens/workflow.test.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { WorkflowScreen } from '../../../src/tui/screens/workflow';
3+
import * as tuiFixtures from '../../fixtures/tui/tui';
4+
5+
// @opentui/core can only load under Bun (see tests/fixtures/tui/opentui.ts),
6+
// so it's replaced with a shared test double. opentui-spinner is inlined in
7+
// vite.config.ts, so its internal `@opentui/core` import resolves to this
8+
// mock too.
9+
vi.mock('@opentui/core', async () => import('../../fixtures/tui/opentui'));
10+
11+
const loadSchemaMock = vi.fn();
12+
vi.mock('../../../src/lib/storage', () => ({
13+
createStorage: () => ({
14+
loadSchema: loadSchemaMock,
15+
}),
16+
}));
17+
18+
vi.mock('../../../src/lib/schema/registryBuilder', () => ({
19+
buildSchemaRegistry: vi.fn().mockReturnValue({}),
20+
}));
21+
22+
// Stub generators — each yields nothing and resolves immediately with a
23+
// minimal but shape-correct result, so tests exercise shell/panel/keymap
24+
// wiring without touching the real CSV/XML pipeline or throwing inside
25+
// routeToResultScreen()'s per-type data access.
26+
const validResult = { valid: true, errorCount: 0, warningCount: 0, issues: [] };
27+
28+
async function* convertGenerator() {
29+
return {
30+
success: true,
31+
steps: [],
32+
duration: 1,
33+
data: { xml: '', outputPath: 'out.xml', csvData: { rows: [] }, validation: validResult },
34+
};
35+
}
36+
async function* validateGenerator() {
37+
return {
38+
success: true,
39+
steps: [],
40+
duration: 1,
41+
data: { validation: validResult, sourceData: { rows: [] } },
42+
};
43+
}
44+
async function* checkGenerator() {
45+
return {
46+
success: true,
47+
steps: [],
48+
duration: 1,
49+
data: { report: {}, hasIssues: false },
50+
};
51+
}
52+
53+
vi.mock('../../../src/lib/workflows/csvConvert', () => ({
54+
convertWorkflow: vi.fn(() => convertGenerator()),
55+
}));
56+
vi.mock('../../../src/lib/workflows/csvValidate', () => ({
57+
validateWorkflow: vi.fn(() => validateGenerator()),
58+
}));
59+
vi.mock('../../../src/lib/workflows/xmlValidate', () => ({
60+
xmlValidateWorkflow: vi.fn(() => validateGenerator()),
61+
}));
62+
vi.mock('../../../src/lib/workflows/crossCheck', () => ({
63+
checkWorkflow: vi.fn(() => checkGenerator()),
64+
}));
65+
66+
describe('WorkflowScreen', () => {
67+
let mockContext: ReturnType<typeof tuiFixtures.createMockContext>;
68+
69+
beforeEach(() => {
70+
vi.clearAllMocks();
71+
mockContext = tuiFixtures.createMockContext();
72+
loadSchemaMock.mockResolvedValue({ success: true, data: '<xsd />' });
73+
});
74+
75+
it('can be instantiated', () => {
76+
const screen = new WorkflowScreen(mockContext);
77+
expect(screen).toBeInstanceOf(WorkflowScreen);
78+
expect(screen.name).toBe('workflow');
79+
});
80+
81+
it('pops immediately when no filePath is provided', async () => {
82+
const screen = new WorkflowScreen(mockContext);
83+
const result = await screen.render({});
84+
expect(result).toEqual({ action: 'pop' });
85+
});
86+
87+
it('mounts the app shell to the renderer root', async () => {
88+
const screen = new WorkflowScreen(mockContext);
89+
await screen.render({ filePath: 'data.csv', workflowType: 'convert' });
90+
91+
expect(mockContext.renderer.root.add).toHaveBeenCalledTimes(1);
92+
const shellRoot = (mockContext.renderer.root.add as any).mock.calls[0][0];
93+
expect(shellRoot.constructor.name).toBe('BoxRenderable');
94+
95+
screen.cleanup();
96+
});
97+
98+
it('wraps the step list in a titled panel matching the workflow title', async () => {
99+
const screen = new WorkflowScreen(mockContext);
100+
await screen.render({ filePath: 'data.csv', workflowType: 'convert' });
101+
102+
const stepsPanel = (screen as any).stepsPanel;
103+
expect(stepsPanel).toBeDefined();
104+
expect(stepsPanel.box.title).toBe('Converting');
105+
106+
screen.cleanup();
107+
});
108+
109+
it('sets the footer to "Processing..." while the workflow runs', async () => {
110+
const screen = new WorkflowScreen(mockContext);
111+
await screen.render({ filePath: 'data.csv', workflowType: 'validate' });
112+
113+
const shellRoot = (mockContext.renderer.root.add as any).mock.calls[0][0];
114+
const children = shellRoot.getChildren();
115+
const footer = children[children.length - 1];
116+
expect(footer.constructor.name).toBe('TextRenderable');
117+
expect(footer.content.chunks[0].text).toBe('Processing...');
118+
119+
screen.cleanup();
120+
});
121+
122+
it('shows a breadcrumb matching the workflow title in the header', async () => {
123+
const screen = new WorkflowScreen(mockContext);
124+
await screen.render({ filePath: 'data.xml', workflowType: 'check' });
125+
126+
const shellRoot = (mockContext.renderer.root.add as any).mock.calls[0][0];
127+
const header = shellRoot.getChildren()[0];
128+
expect(header.content.chunks[0].text).toContain('Checking');
129+
130+
screen.cleanup();
131+
});
132+
133+
it('routes to the dashboard via the keymap when the schema fails to load', async () => {
134+
loadSchemaMock.mockResolvedValue({
135+
success: false,
136+
error: { message: 'schema missing' },
137+
});
138+
139+
const screen = new WorkflowScreen(mockContext);
140+
const resultPromise = screen.render({ filePath: 'data.csv', workflowType: 'convert' });
141+
142+
// Let the schema-load rejection path build the UI and attach the keymap.
143+
await new Promise((resolve) => setTimeout(resolve, 10));
144+
145+
expect(mockContext.renderer.keyInput.on).toHaveBeenCalledWith('keypress', expect.any(Function));
146+
147+
const shellRoot = (mockContext.renderer.root.add as any).mock.calls[0][0];
148+
const children = shellRoot.getChildren();
149+
const footer = children[children.length - 1];
150+
expect(footer.content.chunks[0].text).toContain('Continue');
151+
152+
// Simulate the bound key firing (Enter) via the registered handler.
153+
const handler = (mockContext.renderer.keyInput.on as any).mock.calls[0][1];
154+
handler({ name: 'enter' });
155+
156+
const result = await resultPromise;
157+
expect(result).toEqual({ action: 'replace', screen: 'dashboard' });
158+
159+
screen.cleanup();
160+
});
161+
162+
it('cleanup detaches the keymap and removes the shell from the renderer root', async () => {
163+
loadSchemaMock.mockResolvedValue({
164+
success: false,
165+
error: { message: 'schema missing' },
166+
});
167+
168+
const screen = new WorkflowScreen(mockContext);
169+
screen.render({ filePath: 'data.csv', workflowType: 'convert' }); // don't await — never resolves without a keypress
170+
await new Promise((resolve) => setTimeout(resolve, 10));
171+
172+
screen.cleanup();
173+
174+
expect(mockContext.renderer.keyInput.off).toHaveBeenCalledWith('keypress', expect.any(Function));
175+
expect(mockContext.renderer.root.remove).toHaveBeenCalledTimes(1);
176+
});
177+
});

0 commit comments

Comments
 (0)