feat(studio): operator QoL — flow runnability, audit grouping, batch approvals, welcome celebration (v0.42.0)
Four UX threads stitched into one commit. Each pulls Studio toward Stefan's "zero-learning-curve" goal — the feedback that earned its own memory entry. 1. Flow-Runnability-Indikator ───────────────────────── The Flows tab now fetches `listFlows` and `listModules` in parallel. Each card compares the flow's `requiredCapabilities` against the installed-modules' capability set; rows with missing modules show a "Needs: text.extract@^0" red pill row beneath the path and have their Run button greyed out + tooltip "Install the missing modules first." Operators stop hitting Run → cryptic hub error → frustration. 2. Welcome-Checklist Celebration ───────────────────────────── Once all four checklist signals flip to done, an `_AllDoneCelebration` card replaces the bare "All four steps complete" + Hide button. Three concrete next-threads with action buttons: "Read the audit log", "Set up the daily Today story" (opens the Flows / Today doc inline via `_DocReaderSheet`), and "Build your own module" (opens the architecture doc). Operator who just got set up sees what to do next instead of an empty "what now?" feeling. 3. Audit-Page Time-Bucket Headers + Flow-Run Detail ──────────────────────────────────────────────── The flat event list grows tiny "TODAY / YESTERDAY / EARLIER THIS WEEK / OLDER" section headers — bucket is computed in the operator's local timezone so an event at 23:55 yesterday in Berlin doesn't end up in "today" because UTC happened to spill into a new day. Plus: the event-detail dialog gains a "View flow run" action when the picked event has a `flow_execution`. It opens a drill-down that lists every event in the already-fetched 100-event window sharing the same execution id, sorted ascending — the operator reads the run from step.started top to flow.completed bottom. 4. Approvals-Batch-Aktionen ──────────────────────── Each pending approval card grows a checkbox. When ≥1 selected, a floating action bar appears at the bottom with "N selected · Select all · Clear · Reject all · Approve all". The parent loops sequentially through the per-record SDK calls so a partial failure produces "X done, Y failed" instead of a confusing all-or-nothing rollback. Reject prompts for a reason once and applies to the whole picked set. 13 new ARB keys cover the strings the four features needed. Studio's tests stay green. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
f90d8cc7a7
commit
2002486828
13 changed files with 1215 additions and 64 deletions
|
|
@ -21,6 +21,11 @@ class _ApprovalsPageState extends State<ApprovalsPage>
|
|||
late final TabController _tab;
|
||||
late Future<List<ApprovalRecord>> _pendingFuture;
|
||||
late Future<List<ApprovalRecord>> _historyFuture;
|
||||
/// IDs the operator currently has multi-selected for a
|
||||
/// batch operation. Empty set hides the batch action bar
|
||||
/// and falls back to per-row Approve / Reject buttons.
|
||||
final Set<String> _selectedIds = <String>{};
|
||||
bool _batchInFlight = false;
|
||||
// Reviewer identity recorded in the audit log. Defaults to
|
||||
// the OS user (closest stable identity Studio has without an
|
||||
// auth backend); operators can override it per session.
|
||||
|
|
@ -82,6 +87,73 @@ class _ApprovalsPageState extends State<ApprovalsPage>
|
|||
}
|
||||
}
|
||||
|
||||
void _toggleSelection(String id) {
|
||||
setState(() {
|
||||
if (!_selectedIds.add(id)) _selectedIds.remove(id);
|
||||
});
|
||||
}
|
||||
|
||||
void _selectAll(Iterable<ApprovalRecord> all) {
|
||||
setState(() {
|
||||
_selectedIds
|
||||
..clear()
|
||||
..addAll(all.map((a) => a.id));
|
||||
});
|
||||
}
|
||||
|
||||
void _clearSelection() => setState(() => _selectedIds.clear());
|
||||
|
||||
/// Loops the picked records sequentially through the
|
||||
/// per-record approve / reject calls so a partial failure
|
||||
/// surfaces with a clear "X done, Y failed" toast rather
|
||||
/// than a confusing all-or-nothing rollback. Selection
|
||||
/// drains as items succeed.
|
||||
Future<void> _batchApprove(List<ApprovalRecord> picked) async {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
setState(() => _batchInFlight = true);
|
||||
var ok = 0;
|
||||
var failed = 0;
|
||||
for (final a in picked) {
|
||||
try {
|
||||
await HubService.instance.approve(a.id, _reviewer);
|
||||
ok += 1;
|
||||
_selectedIds.remove(a.id);
|
||||
} catch (_) {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _batchInFlight = false);
|
||||
_toast(failed == 0
|
||||
? l.approvalsBatchApproveDoneToast(ok)
|
||||
: l.approvalsBatchPartialFailure(ok, failed));
|
||||
_refresh();
|
||||
}
|
||||
|
||||
Future<void> _batchReject(List<ApprovalRecord> picked) async {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final reason = await _promptReason(context);
|
||||
if (reason == null || reason.isEmpty) return;
|
||||
setState(() => _batchInFlight = true);
|
||||
var ok = 0;
|
||||
var failed = 0;
|
||||
for (final a in picked) {
|
||||
try {
|
||||
await HubService.instance.reject(a.id, _reviewer, reason);
|
||||
ok += 1;
|
||||
_selectedIds.remove(a.id);
|
||||
} catch (_) {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _batchInFlight = false);
|
||||
_toast(failed == 0
|
||||
? l.approvalsBatchRejectDoneToast(ok)
|
||||
: l.approvalsBatchPartialFailure(ok, failed));
|
||||
_refresh();
|
||||
}
|
||||
|
||||
void _toast(String msg) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||
|
|
@ -148,8 +220,15 @@ class _ApprovalsPageState extends State<ApprovalsPage>
|
|||
children: [
|
||||
_PendingList(
|
||||
future: _pendingFuture,
|
||||
selectedIds: _selectedIds,
|
||||
batchInFlight: _batchInFlight,
|
||||
onApprove: _approve,
|
||||
onReject: _reject,
|
||||
onToggle: _toggleSelection,
|
||||
onSelectAll: _selectAll,
|
||||
onClearSelection: _clearSelection,
|
||||
onBatchApprove: _batchApprove,
|
||||
onBatchReject: _batchReject,
|
||||
onRetry: _refresh,
|
||||
),
|
||||
_HistoryList(
|
||||
|
|
@ -164,14 +243,28 @@ class _ApprovalsPageState extends State<ApprovalsPage>
|
|||
|
||||
class _PendingList extends StatelessWidget {
|
||||
final Future<List<ApprovalRecord>> future;
|
||||
final Set<String> selectedIds;
|
||||
final bool batchInFlight;
|
||||
final void Function(ApprovalRecord) onApprove;
|
||||
final void Function(ApprovalRecord) onReject;
|
||||
final void Function(String id) onToggle;
|
||||
final void Function(Iterable<ApprovalRecord>) onSelectAll;
|
||||
final VoidCallback onClearSelection;
|
||||
final Future<void> Function(List<ApprovalRecord>) onBatchApprove;
|
||||
final Future<void> Function(List<ApprovalRecord>) onBatchReject;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
const _PendingList({
|
||||
required this.future,
|
||||
required this.selectedIds,
|
||||
required this.batchInFlight,
|
||||
required this.onApprove,
|
||||
required this.onReject,
|
||||
required this.onToggle,
|
||||
required this.onSelectAll,
|
||||
required this.onClearSelection,
|
||||
required this.onBatchApprove,
|
||||
required this.onBatchReject,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
|
|
@ -205,24 +298,149 @@ class _PendingList extends StatelessWidget {
|
|||
hint: l.approvalsInboxHint,
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(FaiSpace.xl),
|
||||
itemCount: pending.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md),
|
||||
itemBuilder: (context, i) {
|
||||
final a = pending[i];
|
||||
return _ApprovalCard(
|
||||
approval: a,
|
||||
onApprove: () => onApprove(a),
|
||||
onReject: () => onReject(a),
|
||||
);
|
||||
},
|
||||
final selected = pending
|
||||
.where((a) => selectedIds.contains(a.id))
|
||||
.toList();
|
||||
return Stack(
|
||||
children: [
|
||||
ListView.separated(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
FaiSpace.xl,
|
||||
FaiSpace.xl,
|
||||
FaiSpace.xl,
|
||||
// Keep the last card clear of the floating
|
||||
// batch action bar.
|
||||
selected.isEmpty ? FaiSpace.xl : 96.0,
|
||||
),
|
||||
itemCount: pending.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md),
|
||||
itemBuilder: (context, i) {
|
||||
final a = pending[i];
|
||||
final isSelected = selectedIds.contains(a.id);
|
||||
return _ApprovalCard(
|
||||
approval: a,
|
||||
selected: isSelected,
|
||||
onToggleSelected: () => onToggle(a.id),
|
||||
onApprove: () => onApprove(a),
|
||||
onReject: () => onReject(a),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (selected.isNotEmpty)
|
||||
Positioned(
|
||||
left: FaiSpace.xl,
|
||||
right: FaiSpace.xl,
|
||||
bottom: FaiSpace.lg,
|
||||
child: _BatchActionBar(
|
||||
selectedCount: selected.length,
|
||||
totalCount: pending.length,
|
||||
inFlight: batchInFlight,
|
||||
onSelectAll: () => onSelectAll(pending),
|
||||
onClear: onClearSelection,
|
||||
onApprove: () => onBatchApprove(selected),
|
||||
onReject: () => onBatchReject(selected),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Floating action bar that surfaces when the operator
|
||||
/// multi-selects pending approvals. Lets them approve or
|
||||
/// reject the whole picked set in one round-trip per item;
|
||||
/// the parent loops sequentially so a partial failure stays
|
||||
/// visible per row.
|
||||
class _BatchActionBar extends StatelessWidget {
|
||||
final int selectedCount;
|
||||
final int totalCount;
|
||||
final bool inFlight;
|
||||
final VoidCallback onSelectAll;
|
||||
final VoidCallback onClear;
|
||||
final VoidCallback onApprove;
|
||||
final VoidCallback onReject;
|
||||
|
||||
const _BatchActionBar({
|
||||
required this.selectedCount,
|
||||
required this.totalCount,
|
||||
required this.inFlight,
|
||||
required this.onSelectAll,
|
||||
required this.onClear,
|
||||
required this.onApprove,
|
||||
required this.onReject,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return Material(
|
||||
elevation: 4,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: FaiSpace.lg,
|
||||
vertical: FaiSpace.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.check_box_outlined,
|
||||
size: 18, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Text(
|
||||
l.approvalsBatchSelected(selectedCount),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.md),
|
||||
if (selectedCount < totalCount)
|
||||
TextButton(
|
||||
onPressed: inFlight ? null : onSelectAll,
|
||||
child: Text(l.approvalsSelectAll),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: inFlight ? null : onClear,
|
||||
child: Text(l.approvalsClearSelection),
|
||||
),
|
||||
const Spacer(),
|
||||
OutlinedButton.icon(
|
||||
onPressed: inFlight ? null : onReject,
|
||||
icon: const Icon(Icons.close, size: 14),
|
||||
label: Text(l.approvalsBatchReject),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.error,
|
||||
side: BorderSide(
|
||||
color: theme.colorScheme.error.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
FilledButton.icon(
|
||||
onPressed: inFlight ? null : onApprove,
|
||||
icon: inFlight
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.check, size: 14),
|
||||
label: Text(l.approvalsBatchApprove),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HistoryList extends StatelessWidget {
|
||||
final Future<List<ApprovalRecord>> future;
|
||||
final VoidCallback onRetry;
|
||||
|
|
@ -275,11 +493,15 @@ class _HistoryList extends StatelessWidget {
|
|||
|
||||
class _ApprovalCard extends StatelessWidget {
|
||||
final ApprovalRecord approval;
|
||||
final bool selected;
|
||||
final VoidCallback onToggleSelected;
|
||||
final VoidCallback onApprove;
|
||||
final VoidCallback onReject;
|
||||
|
||||
const _ApprovalCard({
|
||||
required this.approval,
|
||||
required this.selected,
|
||||
required this.onToggleSelected,
|
||||
required this.onApprove,
|
||||
required this.onReject,
|
||||
});
|
||||
|
|
@ -295,6 +517,12 @@ class _ApprovalCard extends StatelessWidget {
|
|||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: selected,
|
||||
onChanged: (_) => onToggleSelected(),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
const SizedBox(width: FaiSpace.xs),
|
||||
FaiPill(
|
||||
label: l.approvalsPillPending,
|
||||
tone: FaiPillTone.warning,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue