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