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;
OtoConsoleSection _activeSection = OtoConsoleSection.overview;
bool _showJobCreateForm = false;
int _refreshGeneration = 0;
final Set<String> _createdJobIDs = <String>{};
OtoActionViewState _jobCreateState = const OtoActionViewState.idle();
bool _isSubmittingJob = false;
final Map<String, OtoActionViewState> _executionActionStates = <String, OtoActionViewState>{};
final Map<String, OtoRunnerActionState> _runnerActionStates = <String, OtoRunnerActionState>{};
final Map<String, OtoActionViewState> _executionActionStates =
<String, OtoActionViewState>{};
final Map<String, OtoRunnerActionState> _runnerActionStates =
<String, OtoRunnerActionState>{};
@override
void initState() {
@ -130,9 +133,7 @@ class _OtoClientAppState extends State<OtoClientApp> {
_isSubmittingJob = false;
if (result.isSuccess) {
_createdJobIDs.add(result.data!.id);
_jobCreateState = OtoActionViewState.succeeded(
message: result.message,
);
_jobCreateState = OtoActionViewState.succeeded(message: result.message);
_refreshCoreStatus();
} else {
_jobCreateState = OtoActionViewState.failed(message: result.message);
@ -142,7 +143,8 @@ class _OtoClientAppState extends State<OtoClientApp> {
Future<void> _submitRunnerSelfUpdate(OtoRunnerSelfUpdateDraft draft) async {
setState(() {
_runnerActionStates[draft.runnerID] = const OtoActionViewState.submitting();
_runnerActionStates[draft.runnerID] =
const OtoActionViewState.submitting();
});
final writeClient = widget.writeClient ?? _defaultWriteClient;
@ -150,7 +152,8 @@ class _OtoClientAppState extends State<OtoClientApp> {
if (!mounted) return;
final accepted = result.state == OtoCoreWriteState.succeeded ||
final accepted =
result.state == OtoCoreWriteState.succeeded ||
result.state == OtoCoreWriteState.deferred;
setState(() {
@ -176,7 +179,8 @@ class _OtoClientAppState extends State<OtoClientApp> {
Future<void> _submitCancelExecution(OtoExecutionCancelDraft draft) async {
setState(() {
_executionActionStates[draft.executionID] = const OtoActionViewState.submitting();
_executionActionStates[draft.executionID] =
const OtoActionViewState.submitting();
});
final writeClient = widget.writeClient ?? _defaultWriteClient;
@ -189,7 +193,8 @@ class _OtoClientAppState extends State<OtoClientApp> {
Future<void> _submitReportExecution(OtoExecutionReportDraft draft) async {
setState(() {
_executionActionStates[draft.executionID] = const OtoActionViewState.submitting();
_executionActionStates[draft.executionID] =
const OtoActionViewState.submitting();
});
final writeClient = widget.writeClient ?? _defaultWriteClient;
@ -202,7 +207,8 @@ class _OtoClientAppState extends State<OtoClientApp> {
Future<void> _submitAppendExecutionLog(OtoExecutionLogDraft draft) async {
setState(() {
_executionActionStates[draft.executionID] = const OtoActionViewState.submitting();
_executionActionStates[draft.executionID] =
const OtoActionViewState.submitting();
});
final writeClient = widget.writeClient ?? _defaultWriteClient;
@ -213,30 +219,42 @@ class _OtoClientAppState extends State<OtoClientApp> {
_handleExecutionActionResult(draft.executionID, result);
}
Future<void> _submitAppendExecutionArtifact(OtoExecutionArtifactDraft draft) async {
Future<void> _submitAppendExecutionArtifact(
OtoExecutionArtifactDraft draft,
) async {
setState(() {
_executionActionStates[draft.executionID] = const OtoActionViewState.submitting();
_executionActionStates[draft.executionID] =
const OtoActionViewState.submitting();
});
final writeClient = widget.writeClient ?? _defaultWriteClient;
final result = await writeClient.appendExecutionArtifact(widget.config, draft);
final result = await writeClient.appendExecutionArtifact(
widget.config,
draft,
);
if (!mounted) return;
_handleExecutionActionResult(draft.executionID, result);
}
void _handleExecutionActionResult(String executionID, OtoCoreWriteResult<void> result) {
void _handleExecutionActionResult(
String executionID,
OtoCoreWriteResult<void> result,
) {
if (!mounted) return;
// Execution actions return `OtoCoreWriteResult<void>.succeeded(null, ...)` or `.deferred(...)`.
// `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;
if (accepted) {
setState(() {
_executionActionStates[executionID] = OtoActionViewState.succeeded(message: result.message);
_executionActionStates[executionID] = OtoActionViewState.succeeded(
message: result.message,
);
});
_refreshCoreStatus();
if (_expandedExecutionID == executionID) {
@ -244,7 +262,9 @@ class _OtoClientAppState extends State<OtoClientApp> {
}
} else {
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 =
_fetchJobs(readClient);
final Future<OtoSurfaceSnapshot<List<OtoExecutionViewModel>>> executionsFuture =
_fetchExecutions(readClient);
final Future<OtoSurfaceSnapshot<List<OtoExecutionViewModel>>>
executionsFuture = _fetchExecutions(readClient);
final Future<OtoSurfaceSnapshot<List<OtoArtifactViewModel>>> artifactsFuture =
_fetchArtifacts(readClient);
final Future<OtoSurfaceSnapshot<List<OtoArtifactViewModel>>>
artifactsFuture = _fetchArtifacts(readClient);
final results = await Future.wait([
connectionFuture,
@ -400,10 +420,7 @@ class _OtoClientAppState extends State<OtoClientApp> {
return const OtoSurfaceSnapshot.empty();
}
final allJobIDs = <String>{
...widget.jobIDs,
..._createdJobIDs,
};
final allJobIDs = <String>{...widget.jobIDs, ..._createdJobIDs};
try {
final results = await Future.wait(
@ -466,10 +483,13 @@ class _OtoClientAppState extends State<OtoClientApp> {
Map<String, String> runnerIDByExecution = <String, String>{};
if (widget.runnerIDs.isNotEmpty) {
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) {
if (statusResult.state == OtoCoreReadState.data && statusResult.data != null) {
if (statusResult.state == OtoCoreReadState.data &&
statusResult.data != null) {
final status = statusResult.data!;
if (status.currentExecutionID.isNotEmpty) {
runnerIDByExecution[status.currentExecutionID] = status.runnerID;
@ -479,7 +499,9 @@ class _OtoClientAppState extends State<OtoClientApp> {
}
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>[];
@ -535,7 +557,9 @@ class _OtoClientAppState extends State<OtoClientApp> {
try {
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>[];
@ -552,16 +576,14 @@ class _OtoClientAppState extends State<OtoClientApp> {
isLoading = true;
break;
}
if (result.state == OtoCoreReadState.data || result.state == OtoCoreReadState.empty) {
if (result.state == OtoCoreReadState.data ||
result.state == OtoCoreReadState.empty) {
if (result.state == OtoCoreReadState.data) {
isEmpty = false;
final records = result.data ?? [];
for (final record in records) {
viewModels.add(
OtoArtifactViewModel(
name: record.name,
path: record.path,
),
OtoArtifactViewModel(name: record.name, path: record.path),
);
}
}
@ -608,8 +630,10 @@ class _OtoClientAppState extends State<OtoClientApp> {
readClient.fetchArtifacts(widget.config, execID);
final results = await Future.wait([logsFuture, artifactsFuture]);
final logsResult = results[0] as OtoCoreReadResult<List<OtoExecutionLogEntry>>;
final artifactsResult = results[1] as OtoCoreReadResult<List<OtoArtifactRecord>>;
final logsResult =
results[0] as OtoCoreReadResult<List<OtoExecutionLogEntry>>;
final artifactsResult =
results[1] as OtoCoreReadResult<List<OtoArtifactRecord>>;
if (!mounted || _expandedExecutionID != execID) {
return;
@ -620,33 +644,35 @@ class _OtoClientAppState extends State<OtoClientApp> {
_logsSnapshot = OtoSurfaceSnapshot.error(logsResult.message);
} else if (logsResult.state == OtoCoreReadState.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();
} else {
_logsSnapshot = OtoSurfaceSnapshot.data(
logsResult.data!
.map((e) => OtoLogEntryViewModel(
timestamp: e.timestamp?.toIso8601String() ?? '',
line: e.line,
))
.map(
(e) => OtoLogEntryViewModel(
timestamp: e.timestamp?.toIso8601String() ?? '',
line: e.line,
),
)
.toList(),
);
}
if (artifactsResult.state == OtoCoreReadState.error) {
_expandedArtifactsSnapshot =
OtoSurfaceSnapshot.error(artifactsResult.message);
_expandedArtifactsSnapshot = OtoSurfaceSnapshot.error(
artifactsResult.message,
);
} else if (artifactsResult.state == OtoCoreReadState.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();
} else {
_expandedArtifactsSnapshot = OtoSurfaceSnapshot.data(
artifactsResult.data!
.map((e) => OtoArtifactViewModel(
name: e.name,
path: e.path,
))
.map((e) => OtoArtifactViewModel(name: e.name, path: e.path))
.toList(),
);
}
@ -684,6 +710,13 @@ class _OtoClientAppState extends State<OtoClientApp> {
onNavigate: (section) {
setState(() {
_activeSection = section;
_showJobCreateForm = false;
});
},
onNewItem: () {
setState(() {
_activeSection = OtoConsoleSection.pipelines;
_showJobCreateForm = true;
});
},
overview: OtoConsoleOverview(
@ -702,11 +735,13 @@ class _OtoClientAppState extends State<OtoClientApp> {
snapshot: _jobsSnapshot,
themeAdapter: themeAdapter,
jobCreateState: _jobCreateState,
showCreateForm: _showJobCreateForm,
onCreateJob: _submitJobCreate,
onExecutionTap: (execID) {
_loadExecutionDetail(execID);
setState(() {
_activeSection = OtoConsoleSection.executions;
_showJobCreateForm = false;
});
},
),
@ -720,6 +755,7 @@ class _OtoClientAppState extends State<OtoClientApp> {
onViewArtifactsSection: () {
setState(() {
_activeSection = OtoConsoleSection.artifacts;
_showJobCreateForm = false;
});
},
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 {
@override
Future<OtoCoreConnectionSnapshot> check(OtoConsoleConfig config) async {
@ -1425,10 +1432,15 @@ void _registerJobCreateTests() {
);
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.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.text('Create'), findsOneWidget);
@ -1490,7 +1502,7 @@ void _registerJobCreateTests() {
createCallCount = 0;
createResult = const OtoCoreWriteResult.failed(message: 'Bad request');
// Need a fresh Pipelines surface
// Need a fresh New Item surface
await tester.pumpWidget(
OtoClientApp(
coreClient: _FakeCoreClient(),
@ -1506,7 +1518,7 @@ void _registerJobCreateTests() {
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Pipelines'));
await tester.tap(_newItemMenuButton());
await tester.pumpAndSettle();
// Fill and submit again
@ -1539,7 +1551,7 @@ void _registerJobCreateTests() {
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Pipelines'));
await tester.tap(_newItemMenuButton());
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).at(0), 'guard-job');
@ -1597,10 +1609,10 @@ void _registerJobCreateTests() {
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Pipelines'));
await tester.tap(_newItemMenuButton());
await tester.pumpAndSettle();
expect(find.textContaining('New Item'), findsNWidgets(2));
expect(find.text('New Item section'), findsOneWidget);
expect(find.byType(TextField), findsNWidgets(2));
});
@ -1654,11 +1666,12 @@ void _registerJobCreateTests() {
// Initial refresh should include existing-job
expect(fetchedJobIDs, ['existing-job']);
// Pipelines tab should have create form visible
await tester.tap(find.byTooltip('Pipelines'));
// New Item should have create form visible.
await tester.tap(_newItemMenuButton());
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 OtoConsoleThemeAdapter? themeAdapter;
final OtoConsoleNavigationCallback? onNavigate;
final VoidCallback? onNewItem;
final OtoCapabilityPack? capabilities;
const OtoConsoleShell({
@ -30,6 +31,7 @@ class OtoConsoleShell extends StatefulWidget {
this.config,
this.themeAdapter,
this.onNavigate,
this.onNewItem,
this.capabilities,
});
@ -39,25 +41,32 @@ class OtoConsoleShell extends StatefulWidget {
class _OtoConsoleShellState extends State<OtoConsoleShell> {
late OtoConsoleSection _section = widget.initialSection;
_MenuAction? _activeAction;
@override
void didUpdateWidget(covariant OtoConsoleShell oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialSection != widget.initialSection) {
_section = widget.initialSection;
if (_section != widget.initialSection) {
_section = widget.initialSection;
_activeAction = null;
}
}
}
@override
Widget build(BuildContext context) {
final theme = widget.themeAdapter ?? const OtoConsoleThemeAdapter();
final descriptor = _SectionDescriptor.forSection(_section);
final descriptor = _activeAction == _MenuAction.newItem
? const _SectionDescriptor.newItem()
: _SectionDescriptor.forSection(_section);
return Scaffold(
backgroundColor: theme.backgroundColor,
body: SafeArea(
child: _JenkinsConsoleFrame(
descriptor: descriptor,
selected: _section,
activeAction: _activeAction,
onSelect: _select,
theme: theme,
child: _buildContent(theme),
@ -66,11 +75,15 @@ class _OtoConsoleShellState extends State<OtoConsoleShell> {
);
}
void _select(OtoConsoleSection section) {
void _select(OtoConsoleSection section, [_MenuAction? action]) {
setState(() {
_section = section;
_activeAction = action;
});
widget.onNavigate?.call(section);
if (action == _MenuAction.newItem) {
widget.onNewItem?.call();
}
}
Widget _buildContent(OtoConsoleThemeAdapter theme) {
@ -86,14 +99,7 @@ class _OtoConsoleShellState extends State<OtoConsoleShell> {
theme: theme,
),
OtoConsoleSection.pipelines =>
widget.pipelines ??
OtoConsoleSectionSurface(
icon: Icons.account_tree_outlined,
title: 'Pipelines / Jobs',
emptyTitle: 'No pipeline jobs',
emptyMessage: 'Job queue is empty.',
theme: theme,
),
widget.pipelines ?? _buildDefaultPipelinesSurface(theme),
OtoConsoleSection.executions =>
widget.executions ??
OtoConsoleSectionSurface(
@ -115,11 +121,34 @@ class _OtoConsoleShellState extends State<OtoConsoleShell> {
OtoConsoleSection.agent =>
widget.agent ?? OtoAgentPanel(capabilities: widget.capabilities),
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,
/// and the icon shared by the breadcrumb and the left menu.
class _SectionDescriptor {
@ -135,50 +164,56 @@ class _SectionDescriptor {
required this.icon,
});
const _SectionDescriptor.newItem()
: section = OtoConsoleSection.pipelines,
label = 'New Item',
groupLabel = 'Workflow',
icon = Icons.add_circle_outline;
static _SectionDescriptor forSection(OtoConsoleSection section) {
return switch (section) {
OtoConsoleSection.overview => const _SectionDescriptor(
section: OtoConsoleSection.overview,
label: 'Overview',
groupLabel: 'Dashboard',
icon: Icons.dashboard_outlined,
),
section: OtoConsoleSection.overview,
label: 'Overview',
groupLabel: 'Dashboard',
icon: Icons.dashboard_outlined,
),
OtoConsoleSection.pipelines => const _SectionDescriptor(
section: OtoConsoleSection.pipelines,
label: 'Pipelines',
groupLabel: 'Workflow',
icon: Icons.account_tree_outlined,
),
section: OtoConsoleSection.pipelines,
label: 'Pipelines',
groupLabel: 'Workflow',
icon: Icons.account_tree_outlined,
),
OtoConsoleSection.executions => const _SectionDescriptor(
section: OtoConsoleSection.executions,
label: 'Executions',
groupLabel: 'Workflow',
icon: Icons.play_circle_outline,
),
section: OtoConsoleSection.executions,
label: 'Executions',
groupLabel: 'Workflow',
icon: Icons.play_circle_outline,
),
OtoConsoleSection.runners => const _SectionDescriptor(
section: OtoConsoleSection.runners,
label: 'Runners',
groupLabel: 'Operations',
icon: Icons.precision_manufacturing_outlined,
),
section: OtoConsoleSection.runners,
label: 'Runners',
groupLabel: 'Operations',
icon: Icons.precision_manufacturing_outlined,
),
OtoConsoleSection.artifacts => const _SectionDescriptor(
section: OtoConsoleSection.artifacts,
label: 'Artifacts',
groupLabel: 'Operations',
icon: Icons.inventory_2_outlined,
),
section: OtoConsoleSection.artifacts,
label: 'Artifacts',
groupLabel: 'Operations',
icon: Icons.inventory_2_outlined,
),
OtoConsoleSection.agent => const _SectionDescriptor(
section: OtoConsoleSection.agent,
label: 'Agent',
groupLabel: 'Admin',
icon: Icons.smart_toy_outlined,
),
section: OtoConsoleSection.agent,
label: 'Agent',
groupLabel: 'Admin',
icon: Icons.smart_toy_outlined,
),
OtoConsoleSection.settings => const _SectionDescriptor(
section: OtoConsoleSection.settings,
label: 'Settings',
groupLabel: 'Admin',
icon: Icons.tune,
),
section: OtoConsoleSection.settings,
label: 'Settings',
groupLabel: 'Admin',
icon: Icons.tune,
),
};
}
}
@ -189,13 +224,16 @@ class _SectionDescriptor {
class _JenkinsConsoleFrame extends StatelessWidget {
final _SectionDescriptor descriptor;
final OtoConsoleSection selected;
final ValueChanged<OtoConsoleSection> onSelect;
final _MenuAction? activeAction;
final void Function(OtoConsoleSection section, [_MenuAction? action])
onSelect;
final OtoConsoleThemeAdapter theme;
final Widget child;
const _JenkinsConsoleFrame({
required this.descriptor,
required this.selected,
required this.activeAction,
required this.onSelect,
required this.theme,
required this.child,
@ -209,7 +247,8 @@ class _JenkinsConsoleFrame extends StatelessWidget {
_JenkinsHeader(
descriptor: descriptor,
theme: theme,
onNewItem: () => onSelect(OtoConsoleSection.pipelines),
onNewItem: () =>
onSelect(OtoConsoleSection.pipelines, _MenuAction.newItem),
),
Expanded(
child: Row(
@ -217,6 +256,7 @@ class _JenkinsConsoleFrame extends StatelessWidget {
children: [
_OtoConsoleLeftMenu(
selected: selected,
activeAction: activeAction,
onSelect: onSelect,
theme: theme,
),
@ -363,10 +403,7 @@ class _RailDivider extends StatelessWidget {
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Container(
height: 1,
color: Colors.white38,
),
child: Container(height: 1, color: Colors.white38),
);
}
}
@ -379,6 +416,7 @@ class _MenuDestination {
final String tooltip;
final IconData icon;
final OtoConsoleSection section;
final _MenuAction? action;
final bool isPrimaryAction;
const _MenuDestination({
@ -386,6 +424,7 @@ class _MenuDestination {
required this.tooltip,
required this.icon,
required this.section,
this.action,
this.isPrimaryAction = false,
});
}
@ -397,11 +436,14 @@ class _MenuDestination {
/// Manage OTO labels without reshaping the frame.
class _OtoConsoleLeftMenu extends StatelessWidget {
final OtoConsoleSection selected;
final ValueChanged<OtoConsoleSection> onSelect;
final _MenuAction? activeAction;
final void Function(OtoConsoleSection section, [_MenuAction? action])
onSelect;
final OtoConsoleThemeAdapter theme;
const _OtoConsoleLeftMenu({
required this.selected,
required this.activeAction,
required this.onSelect,
required this.theme,
});
@ -418,6 +460,7 @@ class _OtoConsoleLeftMenu extends StatelessWidget {
tooltip: 'New Item',
icon: Icons.add_circle_outline,
section: OtoConsoleSection.pipelines,
action: _MenuAction.newItem,
isPrimaryAction: true,
),
_MenuDestination(
@ -475,8 +518,9 @@ class _OtoConsoleLeftMenu extends StatelessWidget {
for (final destination in _primaryDestinations)
_LeftMenuButton(
destination: destination,
selected: !destination.isPrimaryAction && selected == destination.section,
onPressed: () => onSelect(destination.section),
selected: _isDestinationSelected(destination),
onPressed: () =>
onSelect(destination.section, destination.action),
theme: theme,
),
const Spacer(),
@ -484,7 +528,7 @@ class _OtoConsoleLeftMenu extends StatelessWidget {
for (final destination in _adminDestinations)
_LeftMenuButton(
destination: destination,
selected: selected == destination.section,
selected: _isDestinationSelected(destination),
onPressed: () => onSelect(destination.section),
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 {
@ -597,8 +648,7 @@ class _ManageOtoSettingsSurface extends StatelessWidget {
_ManageCategoryCard(
icon: Icons.dns_outlined,
title: 'System',
description:
'Runtime endpoints this console connects to.',
description: 'Runtime endpoints this console connects to.',
theme: theme,
details: [
if (config != null)
@ -617,7 +667,8 @@ class _ManageOtoSettingsSurface extends StatelessWidget {
_ManageCategoryCard(
icon: Icons.security_outlined,
title: 'Security',
description: 'Browser access policy enforced for this console.',
description:
'Browser access policy enforced for this console.',
theme: theme,
detailGroups: const [
OtoConsoleSectionDetailGroup(
@ -625,7 +676,8 @@ class _ManageOtoSettingsSurface extends StatelessWidget {
details: [
OtoConsoleSectionDetail(
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 ValueChanged<String>? onExecutionTap;
final OtoActionViewState? jobCreateState;
final bool showCreateForm;
final ValueChanged<OtoJobCreateDraft>? onCreateJob;
const OtoJobsSurface({
@ -14,6 +15,7 @@ class OtoJobsSurface extends StatefulWidget {
this.themeAdapter,
this.onExecutionTap,
this.jobCreateState,
this.showCreateForm = true,
this.onCreateJob,
});
@ -75,7 +77,7 @@ class _OtoJobsSurfaceState extends State<OtoJobsSurface> {
const SizedBox(height: 16),
_BuildContextStrip(theme: theme, jobCount: jobCount),
const SizedBox(height: 20),
if (widget.jobCreateState != null) ...[
if (widget.showCreateForm && widget.jobCreateState != null) ...[
_CreatePanel(
theme: 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 isEnabled = !createState.isSubmitting;
final canSubmit = _idController.text.trim().isNotEmpty &&
final canSubmit =
_idController.text.trim().isNotEmpty &&
_nameController.text.trim().isNotEmpty &&
isEnabled;
@ -117,7 +123,10 @@ class _OtoJobsSurfaceState extends State<OtoJobsSurface> {
decoration: const InputDecoration(
labelText: 'Job ID',
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
floatingLabelBehavior: FloatingLabelBehavior.never,
),
onChanged: (_) => setState(() {}),
@ -132,7 +141,10 @@ class _OtoJobsSurfaceState extends State<OtoJobsSurface> {
decoration: const InputDecoration(
labelText: 'Job Name',
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
floatingLabelBehavior: FloatingLabelBehavior.never,
),
onChanged: (_) => setState(() {}),
@ -144,7 +156,10 @@ class _OtoJobsSurfaceState extends State<OtoJobsSurface> {
style: ElevatedButton.styleFrom(
backgroundColor: theme.primaryColor,
foregroundColor: theme.backgroundColor,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 8,
),
),
child: createState.isSubmitting
? const SizedBox(
@ -172,7 +187,8 @@ class _OtoJobsSurfaceState extends State<OtoJobsSurface> {
),
),
child: Text(
createState.message ?? (createState.isSuccess ? 'Job created' : 'Job create failed'),
createState.message ??
(createState.isSuccess ? 'Job created' : 'Job create failed'),
style: TextStyle(
color: createState.isSuccess ? theme.primaryColor : Colors.red,
fontSize: 13,
@ -280,7 +296,8 @@ class _OtoJobsSurfaceState extends State<OtoJobsSurface> {
),
const SizedBox(height: 4),
Text(
widget.snapshot.errorMessage ?? 'An unknown error occurred.',
widget.snapshot.errorMessage ??
'An unknown error occurred.',
style: TextStyle(color: theme.textSecondaryColor),
),
],
@ -505,12 +522,18 @@ class _JobTableRow extends StatelessWidget {
const SizedBox(height: 4),
Text(
'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(
flex: 2,
child: job.executionID.isNotEmpty
@ -521,7 +544,10 @@ class _JobTableRow extends StatelessWidget {
)
: Text(
'-',
style: TextStyle(color: theme.textSecondaryColor, fontSize: 12),
style: TextStyle(
color: theme.textSecondaryColor,
fontSize: 12,
),
),
),
Expanded(
@ -572,7 +598,11 @@ class _ExecutionLink extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
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),
Flexible(
child: Text(
@ -613,17 +643,26 @@ class _JobTaskMenu extends StatelessWidget {
spacing: 4,
runSpacing: 2,
children: [
_TaskMenuChip(label: 'Status', enabled: true, onTap: null, theme: theme),
_TaskMenuChip(
label: 'Status',
enabled: true,
onTap: null,
theme: theme,
),
_TaskMenuChip(
label: 'Build History',
enabled: hasExecution,
onTap: hasExecution ? () => onExecutionTap?.call(job.executionID) : null,
onTap: hasExecution
? () => onExecutionTap?.call(job.executionID)
: null,
theme: theme,
),
_TaskMenuChip(
label: 'Stages',
enabled: hasExecution,
onTap: hasExecution ? () => onExecutionTap?.call(job.executionID) : null,
onTap: hasExecution
? () => onExecutionTap?.call(job.executionID)
: null,
theme: theme,
),
],
@ -656,7 +695,11 @@ class _TaskMenuChip extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Text(
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);
});
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 {
const config = OtoConsoleConfig(
serverHttpUrl: 'http://localhost:8080',
@ -887,4 +932,26 @@ void main() {
);
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);
},
);
}