feat(client): Job 생성 폼 표시/숨김 제어와 onNewItem 네비게이션 콜백을 추가한다

- OtoConsoleShell에 onNewItem 콜백 파라미터와 _MenuAction 활성 액션 상태를 도입한다
- OtoJobsSurface에 showCreateForm 파라미터를 추가하여 폼 표시/숨김을 외부에서 제어할 수 있게 한다
- OtoClientApp에 _showJobCreateForm 상태 변수를 추가하여 폼 토글을 구현한다
- 관련 테스트를 업데이트한다
This commit is contained in:
toki 2026-07-06 07:51:19 +09:00
parent 44b9b37d1a
commit 51c5a0f74b
5 changed files with 346 additions and 135 deletions

View file

@ -85,13 +85,16 @@ class _OtoClientAppState extends State<OtoClientApp> {
OtoSurfaceSnapshot<List<OtoArtifactViewModel>>? _expandedArtifactsSnapshot; OtoSurfaceSnapshot<List<OtoArtifactViewModel>>? _expandedArtifactsSnapshot;
OtoConsoleSection _activeSection = OtoConsoleSection.overview; OtoConsoleSection _activeSection = OtoConsoleSection.overview;
bool _showJobCreateForm = false;
int _refreshGeneration = 0; int _refreshGeneration = 0;
final Set<String> _createdJobIDs = <String>{}; final Set<String> _createdJobIDs = <String>{};
OtoActionViewState _jobCreateState = const OtoActionViewState.idle(); OtoActionViewState _jobCreateState = const OtoActionViewState.idle();
bool _isSubmittingJob = false; bool _isSubmittingJob = false;
final Map<String, OtoActionViewState> _executionActionStates = <String, OtoActionViewState>{}; final Map<String, OtoActionViewState> _executionActionStates =
final Map<String, OtoRunnerActionState> _runnerActionStates = <String, OtoRunnerActionState>{}; <String, OtoActionViewState>{};
final Map<String, OtoRunnerActionState> _runnerActionStates =
<String, OtoRunnerActionState>{};
@override @override
void initState() { void initState() {
@ -130,9 +133,7 @@ class _OtoClientAppState extends State<OtoClientApp> {
_isSubmittingJob = false; _isSubmittingJob = false;
if (result.isSuccess) { if (result.isSuccess) {
_createdJobIDs.add(result.data!.id); _createdJobIDs.add(result.data!.id);
_jobCreateState = OtoActionViewState.succeeded( _jobCreateState = OtoActionViewState.succeeded(message: result.message);
message: result.message,
);
_refreshCoreStatus(); _refreshCoreStatus();
} else { } else {
_jobCreateState = OtoActionViewState.failed(message: result.message); _jobCreateState = OtoActionViewState.failed(message: result.message);
@ -142,7 +143,8 @@ class _OtoClientAppState extends State<OtoClientApp> {
Future<void> _submitRunnerSelfUpdate(OtoRunnerSelfUpdateDraft draft) async { Future<void> _submitRunnerSelfUpdate(OtoRunnerSelfUpdateDraft draft) async {
setState(() { setState(() {
_runnerActionStates[draft.runnerID] = const OtoActionViewState.submitting(); _runnerActionStates[draft.runnerID] =
const OtoActionViewState.submitting();
}); });
final writeClient = widget.writeClient ?? _defaultWriteClient; final writeClient = widget.writeClient ?? _defaultWriteClient;
@ -150,7 +152,8 @@ class _OtoClientAppState extends State<OtoClientApp> {
if (!mounted) return; if (!mounted) return;
final accepted = result.state == OtoCoreWriteState.succeeded || final accepted =
result.state == OtoCoreWriteState.succeeded ||
result.state == OtoCoreWriteState.deferred; result.state == OtoCoreWriteState.deferred;
setState(() { setState(() {
@ -176,7 +179,8 @@ class _OtoClientAppState extends State<OtoClientApp> {
Future<void> _submitCancelExecution(OtoExecutionCancelDraft draft) async { Future<void> _submitCancelExecution(OtoExecutionCancelDraft draft) async {
setState(() { setState(() {
_executionActionStates[draft.executionID] = const OtoActionViewState.submitting(); _executionActionStates[draft.executionID] =
const OtoActionViewState.submitting();
}); });
final writeClient = widget.writeClient ?? _defaultWriteClient; final writeClient = widget.writeClient ?? _defaultWriteClient;
@ -189,7 +193,8 @@ class _OtoClientAppState extends State<OtoClientApp> {
Future<void> _submitReportExecution(OtoExecutionReportDraft draft) async { Future<void> _submitReportExecution(OtoExecutionReportDraft draft) async {
setState(() { setState(() {
_executionActionStates[draft.executionID] = const OtoActionViewState.submitting(); _executionActionStates[draft.executionID] =
const OtoActionViewState.submitting();
}); });
final writeClient = widget.writeClient ?? _defaultWriteClient; final writeClient = widget.writeClient ?? _defaultWriteClient;
@ -202,7 +207,8 @@ class _OtoClientAppState extends State<OtoClientApp> {
Future<void> _submitAppendExecutionLog(OtoExecutionLogDraft draft) async { Future<void> _submitAppendExecutionLog(OtoExecutionLogDraft draft) async {
setState(() { setState(() {
_executionActionStates[draft.executionID] = const OtoActionViewState.submitting(); _executionActionStates[draft.executionID] =
const OtoActionViewState.submitting();
}); });
final writeClient = widget.writeClient ?? _defaultWriteClient; final writeClient = widget.writeClient ?? _defaultWriteClient;
@ -213,30 +219,42 @@ class _OtoClientAppState extends State<OtoClientApp> {
_handleExecutionActionResult(draft.executionID, result); _handleExecutionActionResult(draft.executionID, result);
} }
Future<void> _submitAppendExecutionArtifact(OtoExecutionArtifactDraft draft) async { Future<void> _submitAppendExecutionArtifact(
OtoExecutionArtifactDraft draft,
) async {
setState(() { setState(() {
_executionActionStates[draft.executionID] = const OtoActionViewState.submitting(); _executionActionStates[draft.executionID] =
const OtoActionViewState.submitting();
}); });
final writeClient = widget.writeClient ?? _defaultWriteClient; final writeClient = widget.writeClient ?? _defaultWriteClient;
final result = await writeClient.appendExecutionArtifact(widget.config, draft); final result = await writeClient.appendExecutionArtifact(
widget.config,
draft,
);
if (!mounted) return; if (!mounted) return;
_handleExecutionActionResult(draft.executionID, result); _handleExecutionActionResult(draft.executionID, result);
} }
void _handleExecutionActionResult(String executionID, OtoCoreWriteResult<void> result) { void _handleExecutionActionResult(
String executionID,
OtoCoreWriteResult<void> result,
) {
if (!mounted) return; if (!mounted) return;
// Execution actions return `OtoCoreWriteResult<void>.succeeded(null, ...)` or `.deferred(...)`. // Execution actions return `OtoCoreWriteResult<void>.succeeded(null, ...)` or `.deferred(...)`.
// `result.isSuccess` requires `data != null`, so check state directly. // `result.isSuccess` requires `data != null`, so check state directly.
final accepted = result.state == OtoCoreWriteState.succeeded || final accepted =
result.state == OtoCoreWriteState.succeeded ||
result.state == OtoCoreWriteState.deferred; result.state == OtoCoreWriteState.deferred;
if (accepted) { if (accepted) {
setState(() { setState(() {
_executionActionStates[executionID] = OtoActionViewState.succeeded(message: result.message); _executionActionStates[executionID] = OtoActionViewState.succeeded(
message: result.message,
);
}); });
_refreshCoreStatus(); _refreshCoreStatus();
if (_expandedExecutionID == executionID) { if (_expandedExecutionID == executionID) {
@ -244,7 +262,9 @@ class _OtoClientAppState extends State<OtoClientApp> {
} }
} else { } else {
setState(() { setState(() {
_executionActionStates[executionID] = OtoActionViewState.failed(message: result.message); _executionActionStates[executionID] = OtoActionViewState.failed(
message: result.message,
);
}); });
} }
} }
@ -282,11 +302,11 @@ class _OtoClientAppState extends State<OtoClientApp> {
final Future<OtoSurfaceSnapshot<List<OtoJobViewModel>>> jobsFuture = final Future<OtoSurfaceSnapshot<List<OtoJobViewModel>>> jobsFuture =
_fetchJobs(readClient); _fetchJobs(readClient);
final Future<OtoSurfaceSnapshot<List<OtoExecutionViewModel>>> executionsFuture = final Future<OtoSurfaceSnapshot<List<OtoExecutionViewModel>>>
_fetchExecutions(readClient); executionsFuture = _fetchExecutions(readClient);
final Future<OtoSurfaceSnapshot<List<OtoArtifactViewModel>>> artifactsFuture = final Future<OtoSurfaceSnapshot<List<OtoArtifactViewModel>>>
_fetchArtifacts(readClient); artifactsFuture = _fetchArtifacts(readClient);
final results = await Future.wait([ final results = await Future.wait([
connectionFuture, connectionFuture,
@ -400,10 +420,7 @@ class _OtoClientAppState extends State<OtoClientApp> {
return const OtoSurfaceSnapshot.empty(); return const OtoSurfaceSnapshot.empty();
} }
final allJobIDs = <String>{ final allJobIDs = <String>{...widget.jobIDs, ..._createdJobIDs};
...widget.jobIDs,
..._createdJobIDs,
};
try { try {
final results = await Future.wait( final results = await Future.wait(
@ -466,10 +483,13 @@ class _OtoClientAppState extends State<OtoClientApp> {
Map<String, String> runnerIDByExecution = <String, String>{}; Map<String, String> runnerIDByExecution = <String, String>{};
if (widget.runnerIDs.isNotEmpty) { if (widget.runnerIDs.isNotEmpty) {
final runnerStatusResults = await Future.wait( final runnerStatusResults = await Future.wait(
widget.runnerIDs.map((rid) => readClient.fetchRunnerStatus(widget.config, rid)), widget.runnerIDs.map(
(rid) => readClient.fetchRunnerStatus(widget.config, rid),
),
); );
for (final statusResult in runnerStatusResults) { for (final statusResult in runnerStatusResults) {
if (statusResult.state == OtoCoreReadState.data && statusResult.data != null) { if (statusResult.state == OtoCoreReadState.data &&
statusResult.data != null) {
final status = statusResult.data!; final status = statusResult.data!;
if (status.currentExecutionID.isNotEmpty) { if (status.currentExecutionID.isNotEmpty) {
runnerIDByExecution[status.currentExecutionID] = status.runnerID; runnerIDByExecution[status.currentExecutionID] = status.runnerID;
@ -479,7 +499,9 @@ class _OtoClientAppState extends State<OtoClientApp> {
} }
final results = await Future.wait( final results = await Future.wait(
widget.executionIDs.map((id) => readClient.fetchExecution(widget.config, id)), widget.executionIDs.map(
(id) => readClient.fetchExecution(widget.config, id),
),
); );
final viewModels = <OtoExecutionViewModel>[]; final viewModels = <OtoExecutionViewModel>[];
@ -535,7 +557,9 @@ class _OtoClientAppState extends State<OtoClientApp> {
try { try {
final results = await Future.wait( final results = await Future.wait(
widget.executionIDs.map((id) => readClient.fetchArtifacts(widget.config, id)), widget.executionIDs.map(
(id) => readClient.fetchArtifacts(widget.config, id),
),
); );
final viewModels = <OtoArtifactViewModel>[]; final viewModels = <OtoArtifactViewModel>[];
@ -552,16 +576,14 @@ class _OtoClientAppState extends State<OtoClientApp> {
isLoading = true; isLoading = true;
break; break;
} }
if (result.state == OtoCoreReadState.data || result.state == OtoCoreReadState.empty) { if (result.state == OtoCoreReadState.data ||
result.state == OtoCoreReadState.empty) {
if (result.state == OtoCoreReadState.data) { if (result.state == OtoCoreReadState.data) {
isEmpty = false; isEmpty = false;
final records = result.data ?? []; final records = result.data ?? [];
for (final record in records) { for (final record in records) {
viewModels.add( viewModels.add(
OtoArtifactViewModel( OtoArtifactViewModel(name: record.name, path: record.path),
name: record.name,
path: record.path,
),
); );
} }
} }
@ -608,8 +630,10 @@ class _OtoClientAppState extends State<OtoClientApp> {
readClient.fetchArtifacts(widget.config, execID); readClient.fetchArtifacts(widget.config, execID);
final results = await Future.wait([logsFuture, artifactsFuture]); final results = await Future.wait([logsFuture, artifactsFuture]);
final logsResult = results[0] as OtoCoreReadResult<List<OtoExecutionLogEntry>>; final logsResult =
final artifactsResult = results[1] as OtoCoreReadResult<List<OtoArtifactRecord>>; results[0] as OtoCoreReadResult<List<OtoExecutionLogEntry>>;
final artifactsResult =
results[1] as OtoCoreReadResult<List<OtoArtifactRecord>>;
if (!mounted || _expandedExecutionID != execID) { if (!mounted || _expandedExecutionID != execID) {
return; return;
@ -620,33 +644,35 @@ class _OtoClientAppState extends State<OtoClientApp> {
_logsSnapshot = OtoSurfaceSnapshot.error(logsResult.message); _logsSnapshot = OtoSurfaceSnapshot.error(logsResult.message);
} else if (logsResult.state == OtoCoreReadState.loading) { } else if (logsResult.state == OtoCoreReadState.loading) {
_logsSnapshot = const OtoSurfaceSnapshot.loading(); _logsSnapshot = const OtoSurfaceSnapshot.loading();
} else if (logsResult.state == OtoCoreReadState.empty || logsResult.data == null) { } else if (logsResult.state == OtoCoreReadState.empty ||
logsResult.data == null) {
_logsSnapshot = const OtoSurfaceSnapshot.empty(); _logsSnapshot = const OtoSurfaceSnapshot.empty();
} else { } else {
_logsSnapshot = OtoSurfaceSnapshot.data( _logsSnapshot = OtoSurfaceSnapshot.data(
logsResult.data! logsResult.data!
.map((e) => OtoLogEntryViewModel( .map(
timestamp: e.timestamp?.toIso8601String() ?? '', (e) => OtoLogEntryViewModel(
line: e.line, timestamp: e.timestamp?.toIso8601String() ?? '',
)) line: e.line,
),
)
.toList(), .toList(),
); );
} }
if (artifactsResult.state == OtoCoreReadState.error) { if (artifactsResult.state == OtoCoreReadState.error) {
_expandedArtifactsSnapshot = _expandedArtifactsSnapshot = OtoSurfaceSnapshot.error(
OtoSurfaceSnapshot.error(artifactsResult.message); artifactsResult.message,
);
} else if (artifactsResult.state == OtoCoreReadState.loading) { } else if (artifactsResult.state == OtoCoreReadState.loading) {
_expandedArtifactsSnapshot = const OtoSurfaceSnapshot.loading(); _expandedArtifactsSnapshot = const OtoSurfaceSnapshot.loading();
} else if (artifactsResult.state == OtoCoreReadState.empty || artifactsResult.data == null) { } else if (artifactsResult.state == OtoCoreReadState.empty ||
artifactsResult.data == null) {
_expandedArtifactsSnapshot = const OtoSurfaceSnapshot.empty(); _expandedArtifactsSnapshot = const OtoSurfaceSnapshot.empty();
} else { } else {
_expandedArtifactsSnapshot = OtoSurfaceSnapshot.data( _expandedArtifactsSnapshot = OtoSurfaceSnapshot.data(
artifactsResult.data! artifactsResult.data!
.map((e) => OtoArtifactViewModel( .map((e) => OtoArtifactViewModel(name: e.name, path: e.path))
name: e.name,
path: e.path,
))
.toList(), .toList(),
); );
} }
@ -684,6 +710,13 @@ class _OtoClientAppState extends State<OtoClientApp> {
onNavigate: (section) { onNavigate: (section) {
setState(() { setState(() {
_activeSection = section; _activeSection = section;
_showJobCreateForm = false;
});
},
onNewItem: () {
setState(() {
_activeSection = OtoConsoleSection.pipelines;
_showJobCreateForm = true;
}); });
}, },
overview: OtoConsoleOverview( overview: OtoConsoleOverview(
@ -702,11 +735,13 @@ class _OtoClientAppState extends State<OtoClientApp> {
snapshot: _jobsSnapshot, snapshot: _jobsSnapshot,
themeAdapter: themeAdapter, themeAdapter: themeAdapter,
jobCreateState: _jobCreateState, jobCreateState: _jobCreateState,
showCreateForm: _showJobCreateForm,
onCreateJob: _submitJobCreate, onCreateJob: _submitJobCreate,
onExecutionTap: (execID) { onExecutionTap: (execID) {
_loadExecutionDetail(execID); _loadExecutionDetail(execID);
setState(() { setState(() {
_activeSection = OtoConsoleSection.executions; _activeSection = OtoConsoleSection.executions;
_showJobCreateForm = false;
}); });
}, },
), ),
@ -720,6 +755,7 @@ class _OtoClientAppState extends State<OtoClientApp> {
onViewArtifactsSection: () { onViewArtifactsSection: () {
setState(() { setState(() {
_activeSection = OtoConsoleSection.artifacts; _activeSection = OtoConsoleSection.artifacts;
_showJobCreateForm = false;
}); });
}, },
onCancelExecution: _submitCancelExecution, onCancelExecution: _submitCancelExecution,

View file

@ -261,6 +261,13 @@ http.Response _jsonResponse(Map<String, Object?> body) {
); );
} }
Finder _newItemMenuButton() {
return find.ancestor(
of: find.text('New Item'),
matching: find.byType(InkWell),
);
}
class _FakeCoreClient implements OtoCoreConnectionClient { class _FakeCoreClient implements OtoCoreConnectionClient {
@override @override
Future<OtoCoreConnectionSnapshot> check(OtoConsoleConfig config) async { Future<OtoCoreConnectionSnapshot> check(OtoConsoleConfig config) async {
@ -1425,10 +1432,15 @@ void _registerJobCreateTests() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Verify Pipelines tab renders the jobs surface // Pipelines stays a list surface; New Item owns the create form.
await tester.tap(find.byTooltip('Pipelines')); await tester.tap(find.byTooltip('Pipelines'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.textContaining('New Item'), findsNWidgets(2)); expect(find.text('No pipeline jobs'), findsOneWidget);
expect(find.byType(TextField), findsNothing);
await tester.tap(_newItemMenuButton());
await tester.pumpAndSettle();
expect(find.text('New Item section'), findsOneWidget);
expect(find.byType(TextField), findsNWidgets(2)); expect(find.byType(TextField), findsNWidgets(2));
expect(find.text('Create'), findsOneWidget); expect(find.text('Create'), findsOneWidget);
@ -1490,7 +1502,7 @@ void _registerJobCreateTests() {
createCallCount = 0; createCallCount = 0;
createResult = const OtoCoreWriteResult.failed(message: 'Bad request'); createResult = const OtoCoreWriteResult.failed(message: 'Bad request');
// Need a fresh Pipelines surface // Need a fresh New Item surface
await tester.pumpWidget( await tester.pumpWidget(
OtoClientApp( OtoClientApp(
coreClient: _FakeCoreClient(), coreClient: _FakeCoreClient(),
@ -1506,7 +1518,7 @@ void _registerJobCreateTests() {
), ),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Pipelines')); await tester.tap(_newItemMenuButton());
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Fill and submit again // Fill and submit again
@ -1539,7 +1551,7 @@ void _registerJobCreateTests() {
), ),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Pipelines')); await tester.tap(_newItemMenuButton());
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).at(0), 'guard-job'); await tester.enterText(find.byType(TextField).at(0), 'guard-job');
@ -1597,10 +1609,10 @@ void _registerJobCreateTests() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Pipelines')); await tester.tap(_newItemMenuButton());
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.textContaining('New Item'), findsNWidgets(2)); expect(find.text('New Item section'), findsOneWidget);
expect(find.byType(TextField), findsNWidgets(2)); expect(find.byType(TextField), findsNWidgets(2));
}); });
@ -1654,11 +1666,12 @@ void _registerJobCreateTests() {
// Initial refresh should include existing-job // Initial refresh should include existing-job
expect(fetchedJobIDs, ['existing-job']); expect(fetchedJobIDs, ['existing-job']);
// Pipelines tab should have create form visible // New Item should have create form visible.
await tester.tap(find.byTooltip('Pipelines')); await tester.tap(_newItemMenuButton());
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.textContaining('New Item'), findsNWidgets(2)); expect(find.text('New Item section'), findsOneWidget);
expect(find.byType(TextField), findsNWidgets(2));
}); });
} }

View file

@ -15,6 +15,7 @@ class OtoConsoleShell extends StatefulWidget {
final OtoConsoleConfig? config; final OtoConsoleConfig? config;
final OtoConsoleThemeAdapter? themeAdapter; final OtoConsoleThemeAdapter? themeAdapter;
final OtoConsoleNavigationCallback? onNavigate; final OtoConsoleNavigationCallback? onNavigate;
final VoidCallback? onNewItem;
final OtoCapabilityPack? capabilities; final OtoCapabilityPack? capabilities;
const OtoConsoleShell({ const OtoConsoleShell({
@ -30,6 +31,7 @@ class OtoConsoleShell extends StatefulWidget {
this.config, this.config,
this.themeAdapter, this.themeAdapter,
this.onNavigate, this.onNavigate,
this.onNewItem,
this.capabilities, this.capabilities,
}); });
@ -39,25 +41,32 @@ class OtoConsoleShell extends StatefulWidget {
class _OtoConsoleShellState extends State<OtoConsoleShell> { class _OtoConsoleShellState extends State<OtoConsoleShell> {
late OtoConsoleSection _section = widget.initialSection; late OtoConsoleSection _section = widget.initialSection;
_MenuAction? _activeAction;
@override @override
void didUpdateWidget(covariant OtoConsoleShell oldWidget) { void didUpdateWidget(covariant OtoConsoleShell oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.initialSection != widget.initialSection) { if (oldWidget.initialSection != widget.initialSection) {
_section = widget.initialSection; if (_section != widget.initialSection) {
_section = widget.initialSection;
_activeAction = null;
}
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = widget.themeAdapter ?? const OtoConsoleThemeAdapter(); final theme = widget.themeAdapter ?? const OtoConsoleThemeAdapter();
final descriptor = _SectionDescriptor.forSection(_section); final descriptor = _activeAction == _MenuAction.newItem
? const _SectionDescriptor.newItem()
: _SectionDescriptor.forSection(_section);
return Scaffold( return Scaffold(
backgroundColor: theme.backgroundColor, backgroundColor: theme.backgroundColor,
body: SafeArea( body: SafeArea(
child: _JenkinsConsoleFrame( child: _JenkinsConsoleFrame(
descriptor: descriptor, descriptor: descriptor,
selected: _section, selected: _section,
activeAction: _activeAction,
onSelect: _select, onSelect: _select,
theme: theme, theme: theme,
child: _buildContent(theme), child: _buildContent(theme),
@ -66,11 +75,15 @@ class _OtoConsoleShellState extends State<OtoConsoleShell> {
); );
} }
void _select(OtoConsoleSection section) { void _select(OtoConsoleSection section, [_MenuAction? action]) {
setState(() { setState(() {
_section = section; _section = section;
_activeAction = action;
}); });
widget.onNavigate?.call(section); widget.onNavigate?.call(section);
if (action == _MenuAction.newItem) {
widget.onNewItem?.call();
}
} }
Widget _buildContent(OtoConsoleThemeAdapter theme) { Widget _buildContent(OtoConsoleThemeAdapter theme) {
@ -86,14 +99,7 @@ class _OtoConsoleShellState extends State<OtoConsoleShell> {
theme: theme, theme: theme,
), ),
OtoConsoleSection.pipelines => OtoConsoleSection.pipelines =>
widget.pipelines ?? widget.pipelines ?? _buildDefaultPipelinesSurface(theme),
OtoConsoleSectionSurface(
icon: Icons.account_tree_outlined,
title: 'Pipelines / Jobs',
emptyTitle: 'No pipeline jobs',
emptyMessage: 'Job queue is empty.',
theme: theme,
),
OtoConsoleSection.executions => OtoConsoleSection.executions =>
widget.executions ?? widget.executions ??
OtoConsoleSectionSurface( OtoConsoleSectionSurface(
@ -115,11 +121,34 @@ class _OtoConsoleShellState extends State<OtoConsoleShell> {
OtoConsoleSection.agent => OtoConsoleSection.agent =>
widget.agent ?? OtoAgentPanel(capabilities: widget.capabilities), widget.agent ?? OtoAgentPanel(capabilities: widget.capabilities),
OtoConsoleSection.settings => OtoConsoleSection.settings =>
widget.settings ?? _ManageOtoSettingsSurface(theme: theme, config: widget.config), widget.settings ??
_ManageOtoSettingsSurface(theme: theme, config: widget.config),
}; };
} }
Widget _buildDefaultPipelinesSurface(OtoConsoleThemeAdapter theme) {
if (_activeAction == _MenuAction.newItem) {
return OtoConsoleSectionSurface(
icon: Icons.add_circle_outline,
title: 'New Item',
emptyTitle: 'Pipeline creation unavailable',
emptyMessage: 'No create surface is mounted.',
theme: theme,
);
}
return OtoConsoleSectionSurface(
icon: Icons.account_tree_outlined,
title: 'Pipelines / Jobs',
emptyTitle: 'No pipeline jobs',
emptyMessage: 'Job queue is empty.',
theme: theme,
);
}
} }
enum _MenuAction { newItem }
/// Descriptor for the currently selected section: label, breadcrumb group, /// Descriptor for the currently selected section: label, breadcrumb group,
/// and the icon shared by the breadcrumb and the left menu. /// and the icon shared by the breadcrumb and the left menu.
class _SectionDescriptor { class _SectionDescriptor {
@ -135,50 +164,56 @@ class _SectionDescriptor {
required this.icon, required this.icon,
}); });
const _SectionDescriptor.newItem()
: section = OtoConsoleSection.pipelines,
label = 'New Item',
groupLabel = 'Workflow',
icon = Icons.add_circle_outline;
static _SectionDescriptor forSection(OtoConsoleSection section) { static _SectionDescriptor forSection(OtoConsoleSection section) {
return switch (section) { return switch (section) {
OtoConsoleSection.overview => const _SectionDescriptor( OtoConsoleSection.overview => const _SectionDescriptor(
section: OtoConsoleSection.overview, section: OtoConsoleSection.overview,
label: 'Overview', label: 'Overview',
groupLabel: 'Dashboard', groupLabel: 'Dashboard',
icon: Icons.dashboard_outlined, icon: Icons.dashboard_outlined,
), ),
OtoConsoleSection.pipelines => const _SectionDescriptor( OtoConsoleSection.pipelines => const _SectionDescriptor(
section: OtoConsoleSection.pipelines, section: OtoConsoleSection.pipelines,
label: 'Pipelines', label: 'Pipelines',
groupLabel: 'Workflow', groupLabel: 'Workflow',
icon: Icons.account_tree_outlined, icon: Icons.account_tree_outlined,
), ),
OtoConsoleSection.executions => const _SectionDescriptor( OtoConsoleSection.executions => const _SectionDescriptor(
section: OtoConsoleSection.executions, section: OtoConsoleSection.executions,
label: 'Executions', label: 'Executions',
groupLabel: 'Workflow', groupLabel: 'Workflow',
icon: Icons.play_circle_outline, icon: Icons.play_circle_outline,
), ),
OtoConsoleSection.runners => const _SectionDescriptor( OtoConsoleSection.runners => const _SectionDescriptor(
section: OtoConsoleSection.runners, section: OtoConsoleSection.runners,
label: 'Runners', label: 'Runners',
groupLabel: 'Operations', groupLabel: 'Operations',
icon: Icons.precision_manufacturing_outlined, icon: Icons.precision_manufacturing_outlined,
), ),
OtoConsoleSection.artifacts => const _SectionDescriptor( OtoConsoleSection.artifacts => const _SectionDescriptor(
section: OtoConsoleSection.artifacts, section: OtoConsoleSection.artifacts,
label: 'Artifacts', label: 'Artifacts',
groupLabel: 'Operations', groupLabel: 'Operations',
icon: Icons.inventory_2_outlined, icon: Icons.inventory_2_outlined,
), ),
OtoConsoleSection.agent => const _SectionDescriptor( OtoConsoleSection.agent => const _SectionDescriptor(
section: OtoConsoleSection.agent, section: OtoConsoleSection.agent,
label: 'Agent', label: 'Agent',
groupLabel: 'Admin', groupLabel: 'Admin',
icon: Icons.smart_toy_outlined, icon: Icons.smart_toy_outlined,
), ),
OtoConsoleSection.settings => const _SectionDescriptor( OtoConsoleSection.settings => const _SectionDescriptor(
section: OtoConsoleSection.settings, section: OtoConsoleSection.settings,
label: 'Settings', label: 'Settings',
groupLabel: 'Admin', groupLabel: 'Admin',
icon: Icons.tune, icon: Icons.tune,
), ),
}; };
} }
} }
@ -189,13 +224,16 @@ class _SectionDescriptor {
class _JenkinsConsoleFrame extends StatelessWidget { class _JenkinsConsoleFrame extends StatelessWidget {
final _SectionDescriptor descriptor; final _SectionDescriptor descriptor;
final OtoConsoleSection selected; final OtoConsoleSection selected;
final ValueChanged<OtoConsoleSection> onSelect; final _MenuAction? activeAction;
final void Function(OtoConsoleSection section, [_MenuAction? action])
onSelect;
final OtoConsoleThemeAdapter theme; final OtoConsoleThemeAdapter theme;
final Widget child; final Widget child;
const _JenkinsConsoleFrame({ const _JenkinsConsoleFrame({
required this.descriptor, required this.descriptor,
required this.selected, required this.selected,
required this.activeAction,
required this.onSelect, required this.onSelect,
required this.theme, required this.theme,
required this.child, required this.child,
@ -209,7 +247,8 @@ class _JenkinsConsoleFrame extends StatelessWidget {
_JenkinsHeader( _JenkinsHeader(
descriptor: descriptor, descriptor: descriptor,
theme: theme, theme: theme,
onNewItem: () => onSelect(OtoConsoleSection.pipelines), onNewItem: () =>
onSelect(OtoConsoleSection.pipelines, _MenuAction.newItem),
), ),
Expanded( Expanded(
child: Row( child: Row(
@ -217,6 +256,7 @@ class _JenkinsConsoleFrame extends StatelessWidget {
children: [ children: [
_OtoConsoleLeftMenu( _OtoConsoleLeftMenu(
selected: selected, selected: selected,
activeAction: activeAction,
onSelect: onSelect, onSelect: onSelect,
theme: theme, theme: theme,
), ),
@ -363,10 +403,7 @@ class _RailDivider extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Container( child: Container(height: 1, color: Colors.white38),
height: 1,
color: Colors.white38,
),
); );
} }
} }
@ -379,6 +416,7 @@ class _MenuDestination {
final String tooltip; final String tooltip;
final IconData icon; final IconData icon;
final OtoConsoleSection section; final OtoConsoleSection section;
final _MenuAction? action;
final bool isPrimaryAction; final bool isPrimaryAction;
const _MenuDestination({ const _MenuDestination({
@ -386,6 +424,7 @@ class _MenuDestination {
required this.tooltip, required this.tooltip,
required this.icon, required this.icon,
required this.section, required this.section,
this.action,
this.isPrimaryAction = false, this.isPrimaryAction = false,
}); });
} }
@ -397,11 +436,14 @@ class _MenuDestination {
/// Manage OTO labels without reshaping the frame. /// Manage OTO labels without reshaping the frame.
class _OtoConsoleLeftMenu extends StatelessWidget { class _OtoConsoleLeftMenu extends StatelessWidget {
final OtoConsoleSection selected; final OtoConsoleSection selected;
final ValueChanged<OtoConsoleSection> onSelect; final _MenuAction? activeAction;
final void Function(OtoConsoleSection section, [_MenuAction? action])
onSelect;
final OtoConsoleThemeAdapter theme; final OtoConsoleThemeAdapter theme;
const _OtoConsoleLeftMenu({ const _OtoConsoleLeftMenu({
required this.selected, required this.selected,
required this.activeAction,
required this.onSelect, required this.onSelect,
required this.theme, required this.theme,
}); });
@ -418,6 +460,7 @@ class _OtoConsoleLeftMenu extends StatelessWidget {
tooltip: 'New Item', tooltip: 'New Item',
icon: Icons.add_circle_outline, icon: Icons.add_circle_outline,
section: OtoConsoleSection.pipelines, section: OtoConsoleSection.pipelines,
action: _MenuAction.newItem,
isPrimaryAction: true, isPrimaryAction: true,
), ),
_MenuDestination( _MenuDestination(
@ -475,8 +518,9 @@ class _OtoConsoleLeftMenu extends StatelessWidget {
for (final destination in _primaryDestinations) for (final destination in _primaryDestinations)
_LeftMenuButton( _LeftMenuButton(
destination: destination, destination: destination,
selected: !destination.isPrimaryAction && selected == destination.section, selected: _isDestinationSelected(destination),
onPressed: () => onSelect(destination.section), onPressed: () =>
onSelect(destination.section, destination.action),
theme: theme, theme: theme,
), ),
const Spacer(), const Spacer(),
@ -484,7 +528,7 @@ class _OtoConsoleLeftMenu extends StatelessWidget {
for (final destination in _adminDestinations) for (final destination in _adminDestinations)
_LeftMenuButton( _LeftMenuButton(
destination: destination, destination: destination,
selected: selected == destination.section, selected: _isDestinationSelected(destination),
onPressed: () => onSelect(destination.section), onPressed: () => onSelect(destination.section),
theme: theme, theme: theme,
), ),
@ -493,6 +537,13 @@ class _OtoConsoleLeftMenu extends StatelessWidget {
), ),
); );
} }
bool _isDestinationSelected(_MenuDestination destination) {
if (destination.action != null) {
return activeAction == destination.action;
}
return activeAction == null && selected == destination.section;
}
} }
class _LeftMenuButton extends StatelessWidget { class _LeftMenuButton extends StatelessWidget {
@ -597,8 +648,7 @@ class _ManageOtoSettingsSurface extends StatelessWidget {
_ManageCategoryCard( _ManageCategoryCard(
icon: Icons.dns_outlined, icon: Icons.dns_outlined,
title: 'System', title: 'System',
description: description: 'Runtime endpoints this console connects to.',
'Runtime endpoints this console connects to.',
theme: theme, theme: theme,
details: [ details: [
if (config != null) if (config != null)
@ -617,7 +667,8 @@ class _ManageOtoSettingsSurface extends StatelessWidget {
_ManageCategoryCard( _ManageCategoryCard(
icon: Icons.security_outlined, icon: Icons.security_outlined,
title: 'Security', title: 'Security',
description: 'Browser access policy enforced for this console.', description:
'Browser access policy enforced for this console.',
theme: theme, theme: theme,
detailGroups: const [ detailGroups: const [
OtoConsoleSectionDetailGroup( OtoConsoleSectionDetailGroup(
@ -625,7 +676,8 @@ class _ManageOtoSettingsSurface extends StatelessWidget {
details: [ details: [
OtoConsoleSectionDetail( OtoConsoleSectionDetail(
label: 'Browser API policy', label: 'Browser API policy',
value: 'CORS headers or a same-origin proxy are required.', value:
'CORS headers or a same-origin proxy are required.',
), ),
], ],
), ),

View file

@ -6,6 +6,7 @@ class OtoJobsSurface extends StatefulWidget {
final OtoConsoleThemeAdapter? themeAdapter; final OtoConsoleThemeAdapter? themeAdapter;
final ValueChanged<String>? onExecutionTap; final ValueChanged<String>? onExecutionTap;
final OtoActionViewState? jobCreateState; final OtoActionViewState? jobCreateState;
final bool showCreateForm;
final ValueChanged<OtoJobCreateDraft>? onCreateJob; final ValueChanged<OtoJobCreateDraft>? onCreateJob;
const OtoJobsSurface({ const OtoJobsSurface({
@ -14,6 +15,7 @@ class OtoJobsSurface extends StatefulWidget {
this.themeAdapter, this.themeAdapter,
this.onExecutionTap, this.onExecutionTap,
this.jobCreateState, this.jobCreateState,
this.showCreateForm = true,
this.onCreateJob, this.onCreateJob,
}); });
@ -75,7 +77,7 @@ class _OtoJobsSurfaceState extends State<OtoJobsSurface> {
const SizedBox(height: 16), const SizedBox(height: 16),
_BuildContextStrip(theme: theme, jobCount: jobCount), _BuildContextStrip(theme: theme, jobCount: jobCount),
const SizedBox(height: 20), const SizedBox(height: 20),
if (widget.jobCreateState != null) ...[ if (widget.showCreateForm && widget.jobCreateState != null) ...[
_CreatePanel( _CreatePanel(
theme: theme, theme: theme,
child: _buildJobCreateForm(context, theme), child: _buildJobCreateForm(context, theme),
@ -88,10 +90,14 @@ class _OtoJobsSurfaceState extends State<OtoJobsSurface> {
); );
} }
Widget _buildJobCreateForm(BuildContext context, OtoConsoleThemeAdapter theme) { Widget _buildJobCreateForm(
BuildContext context,
OtoConsoleThemeAdapter theme,
) {
final createState = widget.jobCreateState!; final createState = widget.jobCreateState!;
final isEnabled = !createState.isSubmitting; final isEnabled = !createState.isSubmitting;
final canSubmit = _idController.text.trim().isNotEmpty && final canSubmit =
_idController.text.trim().isNotEmpty &&
_nameController.text.trim().isNotEmpty && _nameController.text.trim().isNotEmpty &&
isEnabled; isEnabled;
@ -117,7 +123,10 @@ class _OtoJobsSurfaceState extends State<OtoJobsSurface> {
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Job ID', labelText: 'Job ID',
border: OutlineInputBorder(), border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
floatingLabelBehavior: FloatingLabelBehavior.never, floatingLabelBehavior: FloatingLabelBehavior.never,
), ),
onChanged: (_) => setState(() {}), onChanged: (_) => setState(() {}),
@ -132,7 +141,10 @@ class _OtoJobsSurfaceState extends State<OtoJobsSurface> {
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Job Name', labelText: 'Job Name',
border: OutlineInputBorder(), border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
floatingLabelBehavior: FloatingLabelBehavior.never, floatingLabelBehavior: FloatingLabelBehavior.never,
), ),
onChanged: (_) => setState(() {}), onChanged: (_) => setState(() {}),
@ -144,7 +156,10 @@ class _OtoJobsSurfaceState extends State<OtoJobsSurface> {
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: theme.primaryColor, backgroundColor: theme.primaryColor,
foregroundColor: theme.backgroundColor, foregroundColor: theme.backgroundColor,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 8,
),
), ),
child: createState.isSubmitting child: createState.isSubmitting
? const SizedBox( ? const SizedBox(
@ -172,7 +187,8 @@ class _OtoJobsSurfaceState extends State<OtoJobsSurface> {
), ),
), ),
child: Text( child: Text(
createState.message ?? (createState.isSuccess ? 'Job created' : 'Job create failed'), createState.message ??
(createState.isSuccess ? 'Job created' : 'Job create failed'),
style: TextStyle( style: TextStyle(
color: createState.isSuccess ? theme.primaryColor : Colors.red, color: createState.isSuccess ? theme.primaryColor : Colors.red,
fontSize: 13, fontSize: 13,
@ -280,7 +296,8 @@ class _OtoJobsSurfaceState extends State<OtoJobsSurface> {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
widget.snapshot.errorMessage ?? 'An unknown error occurred.', widget.snapshot.errorMessage ??
'An unknown error occurred.',
style: TextStyle(color: theme.textSecondaryColor), style: TextStyle(color: theme.textSecondaryColor),
), ),
], ],
@ -505,12 +522,18 @@ class _JobTableRow extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'Job ID: ${job.jobID}', 'Job ID: ${job.jobID}',
style: TextStyle(color: theme.textSecondaryColor, fontSize: 12), style: TextStyle(
color: theme.textSecondaryColor,
fontSize: 12,
),
), ),
], ],
), ),
), ),
Expanded(flex: 2, child: _StatusBadge(state: job.state, theme: theme)), Expanded(
flex: 2,
child: _StatusBadge(state: job.state, theme: theme),
),
Expanded( Expanded(
flex: 2, flex: 2,
child: job.executionID.isNotEmpty child: job.executionID.isNotEmpty
@ -521,7 +544,10 @@ class _JobTableRow extends StatelessWidget {
) )
: Text( : Text(
'-', '-',
style: TextStyle(color: theme.textSecondaryColor, fontSize: 12), style: TextStyle(
color: theme.textSecondaryColor,
fontSize: 12,
),
), ),
), ),
Expanded( Expanded(
@ -572,7 +598,11 @@ class _ExecutionLink extends StatelessWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.play_circle_outline, color: theme.primaryColor, size: 14), Icon(
Icons.play_circle_outline,
color: theme.primaryColor,
size: 14,
),
const SizedBox(width: 6), const SizedBox(width: 6),
Flexible( Flexible(
child: Text( child: Text(
@ -613,17 +643,26 @@ class _JobTaskMenu extends StatelessWidget {
spacing: 4, spacing: 4,
runSpacing: 2, runSpacing: 2,
children: [ children: [
_TaskMenuChip(label: 'Status', enabled: true, onTap: null, theme: theme), _TaskMenuChip(
label: 'Status',
enabled: true,
onTap: null,
theme: theme,
),
_TaskMenuChip( _TaskMenuChip(
label: 'Build History', label: 'Build History',
enabled: hasExecution, enabled: hasExecution,
onTap: hasExecution ? () => onExecutionTap?.call(job.executionID) : null, onTap: hasExecution
? () => onExecutionTap?.call(job.executionID)
: null,
theme: theme, theme: theme,
), ),
_TaskMenuChip( _TaskMenuChip(
label: 'Stages', label: 'Stages',
enabled: hasExecution, enabled: hasExecution,
onTap: hasExecution ? () => onExecutionTap?.call(job.executionID) : null, onTap: hasExecution
? () => onExecutionTap?.call(job.executionID)
: null,
theme: theme, theme: theme,
), ),
], ],
@ -656,7 +695,11 @@ class _TaskMenuChip extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Text( child: Text(
label, label,
style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w600), style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w600,
),
), ),
), ),
); );

View file

@ -94,6 +94,51 @@ void main() {
expect(find.textContaining('Remote Run'), findsOneWidget); expect(find.textContaining('Remote Run'), findsOneWidget);
}); });
testWidgets('New Item opens create intent without selecting Pipelines', (
tester,
) async {
const config = OtoConsoleConfig(
serverHttpUrl: 'http://localhost:8080',
serverWireUrl: 'ws://localhost:18080/runner',
);
final navigations = <OtoConsoleSection>[];
var newItemTaps = 0;
await tester.pumpWidget(
MaterialApp(
home: OtoConsoleShell(
config: config,
overview: const OtoConsoleOverview(
config: config,
connection: onlineConnection,
),
pipelines: OtoJobsSurface(
snapshot: const OtoSurfaceSnapshot.empty(),
jobCreateState: const OtoActionViewState.idle(),
onCreateJob: (_) {},
),
onNavigate: navigations.add,
onNewItem: () {
newItemTaps++;
},
),
),
);
await tester.tap(find.text('New Item'));
await tester.pumpAndSettle();
expect(navigations, [OtoConsoleSection.pipelines]);
expect(newItemTaps, 1);
expect(find.text('New Item section'), findsOneWidget);
expect(find.byType(TextField), findsNWidgets(2));
await tester.tap(find.byTooltip('Pipelines'));
await tester.pumpAndSettle();
expect(find.text('Pipelines section'), findsOneWidget);
});
testWidgets('renders section surfaces with empty states', (tester) async { testWidgets('renders section surfaces with empty states', (tester) async {
const config = OtoConsoleConfig( const config = OtoConsoleConfig(
serverHttpUrl: 'http://localhost:8080', serverHttpUrl: 'http://localhost:8080',
@ -887,4 +932,26 @@ void main() {
); );
expect(find.textContaining('Invalid request'), findsOneWidget); expect(find.textContaining('Invalid request'), findsOneWidget);
}); });
testWidgets(
'OtoJobsSurface hides job create form when create intent is off',
(tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: OtoJobsSurface(
snapshot: const OtoSurfaceSnapshot.empty(),
jobCreateState: const OtoActionViewState.idle(),
showCreateForm: false,
onCreateJob: (draft) {},
),
),
),
);
expect(find.byType(TextField), findsNothing);
expect(find.text('Create'), findsNothing);
expect(find.text('No pipeline jobs'), findsOneWidget);
},
);
} }