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
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue