feat(editor): Fix-dialog from diagnostic strip + inline approval in Run tab
Two big operator-UX wins that the previous batch missed:
1. Diagnostic-strip header gets a 'Beheben' (Fix) button next
to 'Copy all'. Clicking opens a focused modal listing every
issue with its quick-fix actions plus an 'apply every quick
fix' master button. Replaces the cramped expand-strip flow
for operators who want a deliberate dialog instead of
scrolling in a 200-px footer.
2. Run tab inlines the Approve / Reject form right under any
step that the hub paused on system.approval@^0. The
FlowRunDriver gains three new methods (host-implements
them; defaults throw with a clear message):
- pendingApprovalIdForStep(flowName, stepId) → String?
- approveApproval(approvalId, reviewer) → Future<void>
- rejectApproval(approvalId, reviewer, reason)
The _InlineApprovalCard polls the driver for ~3s waiting for
the hub to materialise the approval row (event-stream race
against the hub's create), then renders the same form the
Approvals page does — reviewer + optional reason + Approve /
Reject buttons. After submit the hub picks up the decision
on its next poll and emits step.approved / step.rejected,
which the existing event stream already maps to the right
step status.
Closes the operator question 'wie soll das gehen dass da
Freigaben landen?': they land in the Approvals page AND
inline in the Run tab; the inline path is now the natural
workflow.
All 36 editor tests + 24 Studio tests green. Bumped to 0.21.0.
Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
parent
8b38584555
commit
303e318ba8
5 changed files with 593 additions and 1 deletions
|
|
@ -1427,6 +1427,24 @@ class _DiagnosticStripState extends State<_DiagnosticStrip> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opens the "Fix flow issues" modal. Each issue gets a row
|
||||||
|
/// with its quick fixes; rows without a fix surface the
|
||||||
|
/// no-fix hint so the operator knows to edit YAML manually.
|
||||||
|
/// A master "apply all" runs every available fix sequentially,
|
||||||
|
/// pausing on first failure.
|
||||||
|
Future<void> _showFixDialog(List<Issue> issues, ThemeData theme) async {
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => _FixDialog(
|
||||||
|
issues: issues,
|
||||||
|
controller: widget.controller,
|
||||||
|
strings: widget.strings,
|
||||||
|
toneForIssue: _toneForIssue,
|
||||||
|
onApplyFix: _runFix,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
@ -1507,6 +1525,33 @@ class _DiagnosticStripState extends State<_DiagnosticStrip> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// "Beheben" — opens a modal enumerating each
|
||||||
|
// issue with its fixes plus an "apply all"
|
||||||
|
// master button. Only shows when at least one
|
||||||
|
// issue carries a quick fix; otherwise the
|
||||||
|
// strip stays minimal.
|
||||||
|
if (issues.any(
|
||||||
|
(i) => widget.controller.fixesFor(i).isNotEmpty,
|
||||||
|
))
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
child: FilledButton.tonalIcon(
|
||||||
|
onPressed: () => _showFixDialog(issues, theme),
|
||||||
|
icon: const Icon(Icons.auto_fix_high, size: 14),
|
||||||
|
label: Text(
|
||||||
|
widget.strings.diagnosticFixButton,
|
||||||
|
style: _monoTextStyle(size: 11),
|
||||||
|
),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
minimumSize: const Size(0, 28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: widget.strings.diagnosticCopyAll,
|
tooltip: widget.strings.diagnosticCopyAll,
|
||||||
icon: const Icon(Icons.content_copy, size: 14),
|
icon: const Icon(Icons.content_copy, size: 14),
|
||||||
|
|
@ -1818,3 +1863,198 @@ class _IssueHoverCard extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "Fix flow issues" modal — opens from the diagnostic strip's
|
||||||
|
/// header "Beheben" button. Lists each issue with its
|
||||||
|
/// available quick fixes and an "apply every quick fix"
|
||||||
|
/// master action. Replaces the inline expand-strip workflow
|
||||||
|
/// for operators who prefer a deliberate dialog over scrolling
|
||||||
|
/// in a 200-px-tall footer.
|
||||||
|
class _FixDialog extends StatefulWidget {
|
||||||
|
final List<Issue> issues;
|
||||||
|
final FlowYamlCodeController controller;
|
||||||
|
final FlowEditorStrings strings;
|
||||||
|
final Color Function(IssueType, ThemeData) toneForIssue;
|
||||||
|
final Future<void> Function(QuickFix) onApplyFix;
|
||||||
|
|
||||||
|
const _FixDialog({
|
||||||
|
required this.issues,
|
||||||
|
required this.controller,
|
||||||
|
required this.strings,
|
||||||
|
required this.toneForIssue,
|
||||||
|
required this.onApplyFix,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_FixDialog> createState() => _FixDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FixDialogState extends State<_FixDialog> {
|
||||||
|
bool _applyingAll = false;
|
||||||
|
|
||||||
|
Future<void> _applyAll() async {
|
||||||
|
setState(() => _applyingAll = true);
|
||||||
|
try {
|
||||||
|
for (final issue in widget.issues) {
|
||||||
|
final fixes = widget.controller.fixesFor(issue);
|
||||||
|
if (fixes.isEmpty) continue;
|
||||||
|
// Pick the first fix per issue — the analyzer orders
|
||||||
|
// Replace-fixes before Install/AddSource, so a typo
|
||||||
|
// suggestion wins over a remote install round-trip.
|
||||||
|
await widget.onApplyFix(fixes.first);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _applyingAll = false);
|
||||||
|
}
|
||||||
|
if (mounted) Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final strings = widget.strings;
|
||||||
|
final fixCount = widget.issues
|
||||||
|
.where((i) => widget.controller.fixesFor(i).isNotEmpty)
|
||||||
|
.length;
|
||||||
|
return AlertDialog(
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.auto_fix_high,
|
||||||
|
size: 20,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(strings.diagnosticFixDialogTitle),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 640, maxHeight: 480),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
strings.diagnosticFixDialogBody(widget.issues.length),
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
for (final issue in widget.issues)
|
||||||
|
_FixDialogRow(
|
||||||
|
issue: issue,
|
||||||
|
fixes: widget.controller.fixesFor(issue),
|
||||||
|
tone: widget.toneForIssue(issue.type, theme),
|
||||||
|
strings: strings,
|
||||||
|
onApplyFix: (fix) async {
|
||||||
|
await widget.onApplyFix(fix);
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (fixCount > 1)
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
onPressed: _applyingAll ? null : _applyAll,
|
||||||
|
icon: _applyingAll
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.auto_fix_high, size: 16),
|
||||||
|
label: Text(strings.diagnosticFixApplyAll(fixCount)),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: Text(strings.diagnosticFixDialogClose),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FixDialogRow extends StatelessWidget {
|
||||||
|
final Issue issue;
|
||||||
|
final List<QuickFix> fixes;
|
||||||
|
final Color tone;
|
||||||
|
final FlowEditorStrings strings;
|
||||||
|
final Future<void> Function(QuickFix) onApplyFix;
|
||||||
|
|
||||||
|
const _FixDialogRow({
|
||||||
|
required this.issue,
|
||||||
|
required this.fixes,
|
||||||
|
required this.tone,
|
||||||
|
required this.strings,
|
||||||
|
required this.onApplyFix,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border(left: BorderSide(color: tone, width: 3)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
strings.diagnosticLinePrefix(issue.line + 1),
|
||||||
|
style: _monoTextStyle(
|
||||||
|
size: 11,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: SelectableText(
|
||||||
|
issue.message,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (fixes.isEmpty)
|
||||||
|
Text(
|
||||||
|
strings.diagnosticNoFixesAvailable,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: [
|
||||||
|
for (final fix in fixes)
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
onPressed: () => onApplyFix(fix),
|
||||||
|
icon: const Icon(Icons.auto_fix_high, size: 13),
|
||||||
|
label: Text(
|
||||||
|
fix.label,
|
||||||
|
style: _monoTextStyle(size: 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,22 @@ class FlowEditorStrings {
|
||||||
'bitte zuerst beheben',
|
'bitte zuerst beheben',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Inline approval card (Run tab).
|
||||||
|
String get approvalApprove => _t('Approve', 'Freigeben');
|
||||||
|
String get approvalReject => _t('Reject', 'Ablehnen');
|
||||||
|
String get approvalReviewerLabel => _t('Reviewer', 'Prüfer');
|
||||||
|
String get approvalReasonLabel =>
|
||||||
|
_t('Reason (optional)', 'Begründung (optional)');
|
||||||
|
String get approvalSubmitting => _t('Sending…', 'Sende…');
|
||||||
|
String get approvalDecided => _t(
|
||||||
|
'Decision sent. Waiting for the flow to resume…',
|
||||||
|
'Entscheidung gesendet. Warte bis der Flow weiterläuft…',
|
||||||
|
);
|
||||||
|
String get approvalNotFound => _t(
|
||||||
|
'No pending approval for this step yet — the hub may still be creating it.',
|
||||||
|
'Noch keine ausstehende Freigabe für diesen Step — der Hub legt sie ggf. gerade an.',
|
||||||
|
);
|
||||||
|
|
||||||
// Bottom diagnostic strip + hover card.
|
// Bottom diagnostic strip + hover card.
|
||||||
String get diagnosticNoIssues => _t('No issues', 'Keine Probleme');
|
String get diagnosticNoIssues => _t('No issues', 'Keine Probleme');
|
||||||
String diagnosticErrors(int n) =>
|
String diagnosticErrors(int n) =>
|
||||||
|
|
@ -173,6 +189,31 @@ class FlowEditorStrings {
|
||||||
_t('$n warning${n == 1 ? '' : 's'}', '$n Warnungen');
|
_t('$n warning${n == 1 ? '' : 's'}', '$n Warnungen');
|
||||||
String get diagnosticCopyAll => _t('Copy all', 'Alle kopieren');
|
String get diagnosticCopyAll => _t('Copy all', 'Alle kopieren');
|
||||||
String diagnosticLinePrefix(int line) => 'L$line';
|
String diagnosticLinePrefix(int line) => 'L$line';
|
||||||
|
String get diagnosticFixButton => _t('Fix', 'Beheben');
|
||||||
|
String get diagnosticFixDialogTitle =>
|
||||||
|
_t('Fix flow issues', 'Flow-Probleme beheben');
|
||||||
|
String diagnosticFixDialogBody(int n) =>
|
||||||
|
_t(
|
||||||
|
n == 1
|
||||||
|
? '1 issue found. Pick an action below.'
|
||||||
|
: '$n issues found. Pick an action below.',
|
||||||
|
n == 1
|
||||||
|
? '1 Problem gefunden. Aktion unten wählen.'
|
||||||
|
: '$n Probleme gefunden. Aktion unten wählen.',
|
||||||
|
);
|
||||||
|
String get diagnosticFixDialogClose => _t('Close', 'Schließen');
|
||||||
|
String diagnosticFixApplyAll(int n) => _t(
|
||||||
|
n == 1
|
||||||
|
? 'Apply the 1 quick fix'
|
||||||
|
: 'Apply $n quick fixes',
|
||||||
|
n == 1
|
||||||
|
? 'Den 1 Quick-Fix anwenden'
|
||||||
|
: '$n Quick-Fixes anwenden',
|
||||||
|
);
|
||||||
|
String get diagnosticNoFixesAvailable => _t(
|
||||||
|
'No quick fix available — review the message and edit the YAML.',
|
||||||
|
'Kein Quick-Fix verfügbar — Meldung lesen und das YAML manuell anpassen.',
|
||||||
|
);
|
||||||
|
|
||||||
// Analyzer-emitted messages — used by FlowAnalyzer via the
|
// Analyzer-emitted messages — used by FlowAnalyzer via the
|
||||||
// [AnalyzerStrings] adapter below so the same strings can be
|
// [AnalyzerStrings] adapter below so the same strings can be
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,40 @@ abstract class FlowRunDriver {
|
||||||
/// A real implementation hits the hub's ModuleInfo RPC and
|
/// A real implementation hits the hub's ModuleInfo RPC and
|
||||||
/// maps the result to [ModuleSpec].
|
/// maps the result to [ModuleSpec].
|
||||||
Future<ModuleSpec?> moduleInfo(String capability) async => null;
|
Future<ModuleSpec?> moduleInfo(String capability) async => null;
|
||||||
|
|
||||||
|
/// Resolve the pending approval id for `(flowName, stepId)`
|
||||||
|
/// when the flow is paused on a `system.approval@^0` step.
|
||||||
|
/// Default returns `null` so legacy hosts that didn't update
|
||||||
|
/// keep compiling — the editor's Run tab then falls back to
|
||||||
|
/// directing the operator at the standalone Approvals page.
|
||||||
|
Future<String?> pendingApprovalIdForStep({
|
||||||
|
required String flowName,
|
||||||
|
required String stepId,
|
||||||
|
}) async =>
|
||||||
|
null;
|
||||||
|
|
||||||
|
/// Approve the named approval. `reviewer` is the audit-log
|
||||||
|
/// identity the host wants attributed (typically OS user +
|
||||||
|
/// '@studio'). Throws on hub-side failure; UI surfaces it.
|
||||||
|
Future<void> approveApproval({
|
||||||
|
required String approvalId,
|
||||||
|
required String reviewer,
|
||||||
|
}) async {
|
||||||
|
throw UnsupportedError(
|
||||||
|
'FlowRunDriver host did not implement approveApproval',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject the named approval with a free-form reason.
|
||||||
|
Future<void> rejectApproval({
|
||||||
|
required String approvalId,
|
||||||
|
required String reviewer,
|
||||||
|
required String reason,
|
||||||
|
}) async {
|
||||||
|
throw UnsupportedError(
|
||||||
|
'FlowRunDriver host did not implement rejectApproval',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Declared inputs + outputs of one installed module, as seen
|
/// Declared inputs + outputs of one installed module, as seen
|
||||||
|
|
|
||||||
|
|
@ -357,6 +357,25 @@ class _RunTabState extends State<RunTab> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
if (state.kind == _StepKind.awaiting) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 24),
|
||||||
|
child: _InlineApprovalCard(
|
||||||
|
stepId: id,
|
||||||
|
flowName: _runFlowName ?? '',
|
||||||
|
driver: widget.driver,
|
||||||
|
strings: widget.strings,
|
||||||
|
onDecided: () {
|
||||||
|
// After approve/reject the hub keeps polling
|
||||||
|
// and either resumes the flow or fails it; the
|
||||||
|
// event stream surfaces the outcome. Nothing to
|
||||||
|
// do here besides redraw so the buttons go away.
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -707,3 +726,261 @@ class _CopyableErrorBoxState extends State<_CopyableErrorBox> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inline Approve / Reject card rendered under a step that
|
||||||
|
/// the hub has paused on `system.approval@^0`. Removes the
|
||||||
|
/// "go to the Approvals page" detour the operator otherwise
|
||||||
|
/// has to take — the same decision can happen in-context while
|
||||||
|
/// the rest of the run is on screen.
|
||||||
|
///
|
||||||
|
/// Lifecycle:
|
||||||
|
/// 1. Card mounts in awaiting-state with a fetch-in-flight
|
||||||
|
/// indicator.
|
||||||
|
/// 2. Driver returns the pending approval id for this
|
||||||
|
/// (flowName, stepId) pair. Card switches to the Approve /
|
||||||
|
/// Reject form.
|
||||||
|
/// 3. Operator submits a decision. Card disables further
|
||||||
|
/// interaction and waits — the next event (step.approved /
|
||||||
|
/// step.rejected) is what causes the hub to resume the
|
||||||
|
/// flow, and the StepStarted/StepFailed event will redraw
|
||||||
|
/// the parent step row + remove this card.
|
||||||
|
class _InlineApprovalCard extends StatefulWidget {
|
||||||
|
final String stepId;
|
||||||
|
final String flowName;
|
||||||
|
final FlowRunDriver? driver;
|
||||||
|
final FlowEditorStrings strings;
|
||||||
|
final VoidCallback onDecided;
|
||||||
|
|
||||||
|
const _InlineApprovalCard({
|
||||||
|
required this.stepId,
|
||||||
|
required this.flowName,
|
||||||
|
required this.driver,
|
||||||
|
required this.strings,
|
||||||
|
required this.onDecided,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_InlineApprovalCard> createState() => _InlineApprovalCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InlineApprovalCardState extends State<_InlineApprovalCard> {
|
||||||
|
String? _approvalId;
|
||||||
|
bool _fetching = true;
|
||||||
|
bool _submitting = false;
|
||||||
|
bool _submitted = false;
|
||||||
|
String? _error;
|
||||||
|
late final TextEditingController _reasonCtrl;
|
||||||
|
late final TextEditingController _reviewerCtrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_reasonCtrl = TextEditingController();
|
||||||
|
_reviewerCtrl = TextEditingController(text: _defaultReviewer());
|
||||||
|
_fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_reasonCtrl.dispose();
|
||||||
|
_reviewerCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _defaultReviewer() {
|
||||||
|
final user = Platform.environment['USER'] ??
|
||||||
|
Platform.environment['USERNAME'] ??
|
||||||
|
'studio';
|
||||||
|
return '$user@studio';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetch() async {
|
||||||
|
final driver = widget.driver;
|
||||||
|
if (driver == null) {
|
||||||
|
setState(() => _fetching = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The hub creates the approval row when the flow hits the
|
||||||
|
// awaiting state. Race against the event arrival: poll up
|
||||||
|
// to 5 × 600 ms before giving up.
|
||||||
|
for (var i = 0; i < 5; i++) {
|
||||||
|
try {
|
||||||
|
final id = await driver.pendingApprovalIdForStep(
|
||||||
|
flowName: widget.flowName,
|
||||||
|
stepId: widget.stepId,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (id != null) {
|
||||||
|
setState(() {
|
||||||
|
_approvalId = id;
|
||||||
|
_fetching = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_fetching = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Future.delayed(const Duration(milliseconds: 600));
|
||||||
|
}
|
||||||
|
if (mounted) setState(() => _fetching = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _decide({required bool approve}) async {
|
||||||
|
final id = _approvalId;
|
||||||
|
final driver = widget.driver;
|
||||||
|
if (id == null || driver == null) return;
|
||||||
|
setState(() => _submitting = true);
|
||||||
|
try {
|
||||||
|
if (approve) {
|
||||||
|
await driver.approveApproval(
|
||||||
|
approvalId: id,
|
||||||
|
reviewer: _reviewerCtrl.text.trim(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await driver.rejectApproval(
|
||||||
|
approvalId: id,
|
||||||
|
reviewer: _reviewerCtrl.text.trim(),
|
||||||
|
reason: _reasonCtrl.text.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_submitted = true;
|
||||||
|
_submitting = false;
|
||||||
|
});
|
||||||
|
widget.onDecided();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_submitting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final strings = widget.strings;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(FaiSpace.sm),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.colorScheme.tertiary.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _fetching
|
||||||
|
? Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
const SizedBox(width: FaiSpace.sm),
|
||||||
|
Text(strings.runAwaitingApproval),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: _submitted
|
||||||
|
? Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.check_circle_outline,
|
||||||
|
size: 16,
|
||||||
|
color: theme.colorScheme.tertiary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: FaiSpace.sm),
|
||||||
|
Flexible(child: Text(strings.approvalDecided)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: _approvalId == null
|
||||||
|
? Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.hourglass_empty,
|
||||||
|
size: 16,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: FaiSpace.sm),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
_error ?? strings.approvalNotFound,
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _reviewerCtrl,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: strings.approvalReviewerLabel,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: FaiSpace.md),
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: TextField(
|
||||||
|
controller: _reasonCtrl,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: strings.approvalReasonLabel,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_error != null) ...[
|
||||||
|
const SizedBox(height: FaiSpace.sm),
|
||||||
|
Text(
|
||||||
|
_error!,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: FaiSpace.sm),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _submitting
|
||||||
|
? null
|
||||||
|
: () => _decide(approve: true),
|
||||||
|
icon: const Icon(Icons.check, size: 16),
|
||||||
|
label: Text(
|
||||||
|
_submitting
|
||||||
|
? strings.approvalSubmitting
|
||||||
|
: strings.approvalApprove,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: FaiSpace.sm),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _submitting
|
||||||
|
? null
|
||||||
|
: () => _decide(approve: false),
|
||||||
|
icon: const Icon(Icons.close, size: 16),
|
||||||
|
label: Text(strings.approvalReject),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
name: fai_studio_flow_editor
|
name: fai_studio_flow_editor
|
||||||
description: Swappable inline YAML editor for F∆I Studio flows.
|
description: Swappable inline YAML editor for F∆I Studio flows.
|
||||||
version: 0.20.1
|
version: 0.21.0
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
repository: https://git.flemming.ai/fai/studio-flow-editor
|
repository: https://git.flemming.ai/fai/studio-flow-editor
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue