diff --git a/apps/client/lib/src/app/oto_client_app.dart b/apps/client/lib/src/app/oto_client_app.dart index 8e9ce86..bcc8661 100644 --- a/apps/client/lib/src/app/oto_client_app.dart +++ b/apps/client/lib/src/app/oto_client_app.dart @@ -85,13 +85,16 @@ class _OtoClientAppState extends State { OtoSurfaceSnapshot>? _expandedArtifactsSnapshot; OtoConsoleSection _activeSection = OtoConsoleSection.overview; + bool _showJobCreateForm = false; int _refreshGeneration = 0; final Set _createdJobIDs = {}; OtoActionViewState _jobCreateState = const OtoActionViewState.idle(); bool _isSubmittingJob = false; - final Map _executionActionStates = {}; - final Map _runnerActionStates = {}; + final Map _executionActionStates = + {}; + final Map _runnerActionStates = + {}; @override void initState() { @@ -130,9 +133,7 @@ class _OtoClientAppState extends State { _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 { Future _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 { 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 { Future _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 { Future _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 { Future _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 { _handleExecutionActionResult(draft.executionID, result); } - Future _submitAppendExecutionArtifact(OtoExecutionArtifactDraft draft) async { + Future _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 result) { + void _handleExecutionActionResult( + String executionID, + OtoCoreWriteResult result, + ) { if (!mounted) return; // Execution actions return `OtoCoreWriteResult.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 { } } else { setState(() { - _executionActionStates[executionID] = OtoActionViewState.failed(message: result.message); + _executionActionStates[executionID] = OtoActionViewState.failed( + message: result.message, + ); }); } } @@ -282,11 +302,11 @@ class _OtoClientAppState extends State { final Future>> jobsFuture = _fetchJobs(readClient); - final Future>> executionsFuture = - _fetchExecutions(readClient); + final Future>> + executionsFuture = _fetchExecutions(readClient); - final Future>> artifactsFuture = - _fetchArtifacts(readClient); + final Future>> + artifactsFuture = _fetchArtifacts(readClient); final results = await Future.wait([ connectionFuture, @@ -400,10 +420,7 @@ class _OtoClientAppState extends State { return const OtoSurfaceSnapshot.empty(); } - final allJobIDs = { - ...widget.jobIDs, - ..._createdJobIDs, - }; + final allJobIDs = {...widget.jobIDs, ..._createdJobIDs}; try { final results = await Future.wait( @@ -466,10 +483,13 @@ class _OtoClientAppState extends State { Map runnerIDByExecution = {}; 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 { } 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 = []; @@ -535,7 +557,9 @@ class _OtoClientAppState extends State { 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 = []; @@ -552,16 +576,14 @@ class _OtoClientAppState extends State { 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 { readClient.fetchArtifacts(widget.config, execID); final results = await Future.wait([logsFuture, artifactsFuture]); - final logsResult = results[0] as OtoCoreReadResult>; - final artifactsResult = results[1] as OtoCoreReadResult>; + final logsResult = + results[0] as OtoCoreReadResult>; + final artifactsResult = + results[1] as OtoCoreReadResult>; if (!mounted || _expandedExecutionID != execID) { return; @@ -620,33 +644,35 @@ class _OtoClientAppState extends State { _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 { onNavigate: (section) { setState(() { _activeSection = section; + _showJobCreateForm = false; + }); + }, + onNewItem: () { + setState(() { + _activeSection = OtoConsoleSection.pipelines; + _showJobCreateForm = true; }); }, overview: OtoConsoleOverview( @@ -702,11 +735,13 @@ class _OtoClientAppState extends State { 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 { onViewArtifactsSection: () { setState(() { _activeSection = OtoConsoleSection.artifacts; + _showJobCreateForm = false; }); }, onCancelExecution: _submitCancelExecution, diff --git a/apps/client/test/widget_test.dart b/apps/client/test/widget_test.dart index 5ea3c9f..fc52f93 100644 --- a/apps/client/test/widget_test.dart +++ b/apps/client/test/widget_test.dart @@ -261,6 +261,13 @@ http.Response _jsonResponse(Map body) { ); } +Finder _newItemMenuButton() { + return find.ancestor( + of: find.text('New Item'), + matching: find.byType(InkWell), + ); +} + class _FakeCoreClient implements OtoCoreConnectionClient { @override Future 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)); }); } diff --git a/packages/flutter/oto_console/lib/src/oto_console_shell.dart b/packages/flutter/oto_console/lib/src/oto_console_shell.dart index ad1a9aa..0a985e7 100644 --- a/packages/flutter/oto_console/lib/src/oto_console_shell.dart +++ b/packages/flutter/oto_console/lib/src/oto_console_shell.dart @@ -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 { 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 { ); } - 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 { 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 { 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 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 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.', ), ], ), diff --git a/packages/flutter/oto_console/lib/src/oto_jobs_surface.dart b/packages/flutter/oto_console/lib/src/oto_jobs_surface.dart index 25edbc7..e4d698f 100644 --- a/packages/flutter/oto_console/lib/src/oto_jobs_surface.dart +++ b/packages/flutter/oto_console/lib/src/oto_jobs_surface.dart @@ -6,6 +6,7 @@ class OtoJobsSurface extends StatefulWidget { final OtoConsoleThemeAdapter? themeAdapter; final ValueChanged? onExecutionTap; final OtoActionViewState? jobCreateState; + final bool showCreateForm; final ValueChanged? 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 { 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 { ); } - 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 { 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 { 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 { 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 { ), ), 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 { ), 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, + ), ), ), ); diff --git a/packages/flutter/oto_console/test/oto_console_test.dart b/packages/flutter/oto_console/test/oto_console_test.dart index 303130f..47c299b 100644 --- a/packages/flutter/oto_console/test/oto_console_test.dart +++ b/packages/flutter/oto_console/test/oto_console_test.dart @@ -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 = []; + 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); + }, + ); }