feat(client): Job 생성 폼 표시/숨김 제어와 onNewItem 네비게이션 콜백을 추가한다
- OtoConsoleShell에 onNewItem 콜백 파라미터와 _MenuAction 활성 액션 상태를 도입한다 - OtoJobsSurface에 showCreateForm 파라미터를 추가하여 폼 표시/숨김을 외부에서 제어할 수 있게 한다 - OtoClientApp에 _showJobCreateForm 상태 변수를 추가하여 폼 토글을 구현한다 - 관련 테스트를 업데이트한다
This commit is contained in:
parent
44b9b37d1a
commit
51c5a0f74b
5 changed files with 346 additions and 135 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue