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
|
|
@ -19,18 +19,49 @@ class FlowsPage extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _FlowsPageState extends State<FlowsPage> {
|
||||
late Future<List<SavedFlow>> _future;
|
||||
late Future<_FlowsBundle> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = HubService.instance.listFlows();
|
||||
_future = _load();
|
||||
}
|
||||
|
||||
/// Pulls the saved flows AND the installed-modules list in
|
||||
/// parallel so the page can compute "is this flow runnable"
|
||||
/// per row from the same data the Run button gates on.
|
||||
Future<_FlowsBundle> _load() async {
|
||||
final results = await Future.wait([
|
||||
HubService.instance.listFlows(),
|
||||
HubService.instance.listModules().catchError((_) => <ModuleSummary>[]),
|
||||
]);
|
||||
return _FlowsBundle(
|
||||
flows: results[0] as List<SavedFlow>,
|
||||
installedCapabilities: (results[1] as List<ModuleSummary>)
|
||||
.expand((m) => m.capabilities)
|
||||
.toSet(),
|
||||
);
|
||||
}
|
||||
|
||||
void _refresh() => setState(() {
|
||||
_future = HubService.instance.listFlows();
|
||||
_future = _load();
|
||||
});
|
||||
|
||||
/// Returns the verbatim `use:` strings whose bare capability
|
||||
/// name is not provided by any installed module. The bare
|
||||
/// name is everything left of the first `@`; version
|
||||
/// constraints are tolerated at this stage — the hub does
|
||||
/// the full resolution at run time.
|
||||
List<String> _missing(SavedFlow flow, Set<String> installed) {
|
||||
final out = <String>[];
|
||||
for (final cap in flow.requiredCapabilities) {
|
||||
final at = cap.indexOf('@');
|
||||
final name = at >= 0 ? cap.substring(0, at) : cap;
|
||||
if (!installed.contains(name)) out.add(cap);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<void> _runFlow(SavedFlow flow) async {
|
||||
final inputs = await _FlowInputDialog.show(context, flow);
|
||||
if (inputs == null) return;
|
||||
|
|
@ -62,7 +93,7 @@ class _FlowsPageState extends State<FlowsPage> {
|
|||
const SizedBox(width: FaiSpace.sm),
|
||||
],
|
||||
),
|
||||
body: FutureBuilder<List<SavedFlow>>(
|
||||
body: FutureBuilder<_FlowsBundle>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
|
|
@ -80,7 +111,8 @@ class _FlowsPageState extends State<FlowsPage> {
|
|||
),
|
||||
);
|
||||
}
|
||||
final flows = snapshot.data ?? [];
|
||||
final bundle = snapshot.data ?? const _FlowsBundle.empty();
|
||||
final flows = bundle.flows;
|
||||
if (flows.isEmpty) {
|
||||
return FaiEmptyState(
|
||||
icon: Icons.account_tree_outlined,
|
||||
|
|
@ -92,8 +124,15 @@ class _FlowsPageState extends State<FlowsPage> {
|
|||
padding: const EdgeInsets.all(FaiSpace.xl),
|
||||
itemCount: flows.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md),
|
||||
itemBuilder: (context, i) =>
|
||||
_FlowCard(flow: flows[i], onRun: () => _runFlow(flows[i])),
|
||||
itemBuilder: (context, i) {
|
||||
final flow = flows[i];
|
||||
final missing = _missing(flow, bundle.installedCapabilities);
|
||||
return _FlowCard(
|
||||
flow: flow,
|
||||
missingCapabilities: missing,
|
||||
onRun: missing.isEmpty ? () => _runFlow(flow) : null,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -101,15 +140,42 @@ class _FlowsPageState extends State<FlowsPage> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Snapshot of everything the Flows page needs in one render
|
||||
/// pass: the saved flows themselves and the set of installed
|
||||
/// capability names. Stored as a single Future so the
|
||||
/// FutureBuilder transitions atomically (no flicker between
|
||||
/// "flows loaded but modules pending").
|
||||
class _FlowsBundle {
|
||||
final List<SavedFlow> flows;
|
||||
final Set<String> installedCapabilities;
|
||||
const _FlowsBundle({
|
||||
required this.flows,
|
||||
required this.installedCapabilities,
|
||||
});
|
||||
const _FlowsBundle.empty()
|
||||
: flows = const [],
|
||||
installedCapabilities = const {};
|
||||
}
|
||||
|
||||
class _FlowCard extends StatelessWidget {
|
||||
final SavedFlow flow;
|
||||
final VoidCallback onRun;
|
||||
final List<String> missingCapabilities;
|
||||
/// Null when the flow can't run (missing modules); the
|
||||
/// Run button greys out + a tooltip points the operator
|
||||
/// at the install path. Non-null otherwise.
|
||||
final VoidCallback? onRun;
|
||||
|
||||
const _FlowCard({required this.flow, required this.onRun});
|
||||
const _FlowCard({
|
||||
required this.flow,
|
||||
required this.missingCapabilities,
|
||||
required this.onRun,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final isRunnable = missingCapabilities.isEmpty;
|
||||
return FaiCard(
|
||||
child: Row(
|
||||
children: [
|
||||
|
|
@ -137,6 +203,29 @@ class _FlowCard extends StatelessWidget {
|
|||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (!isRunnable) ...[
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
Wrap(
|
||||
spacing: FaiSpace.xs,
|
||||
runSpacing: FaiSpace.xs,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
l.flowsMissingModulesLabel,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
for (final cap in missingCapabilities)
|
||||
FaiPill(
|
||||
label: cap,
|
||||
tone: FaiPillTone.danger,
|
||||
monospace: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -146,10 +235,13 @@ class _FlowCard extends StatelessWidget {
|
|||
monospace: true,
|
||||
),
|
||||
const SizedBox(width: FaiSpace.md),
|
||||
FilledButton.icon(
|
||||
onPressed: onRun,
|
||||
icon: const Icon(Icons.play_arrow, size: 16),
|
||||
label: Text(AppLocalizations.of(context)!.flowsRunButton),
|
||||
Tooltip(
|
||||
message: isRunnable ? '' : l.flowsRunDisabledTooltip,
|
||||
child: FilledButton.icon(
|
||||
onPressed: onRun,
|
||||
icon: const Icon(Icons.play_arrow, size: 16),
|
||||
label: Text(l.flowsRunButton),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue