feat: honest install badge — classify store resolvability before the click
Split each flow's missing capabilities into store-resolvable ones (amber chip + Install link, which now installs only those) and a quiet 'not in store' chip whose tooltip explains the three recovery paths in place: local install via chain install --link, adding the providing store, or configuring the MCP/n8n integration. The analyzer's not-in-store message names the same three paths (EN+DE). Previously the list's Install action covered every missing capability and could end in the hub's 'no store entry' error. Also replace a private-looking capability example name in a doc comment and test with a neutral placeholder. (0.25.0) Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
parent
7d6a575cae
commit
ab97e5e834
8 changed files with 340 additions and 105 deletions
21
CHANGELOG.md
21
CHANGELOG.md
|
|
@ -1,5 +1,26 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.25.0
|
||||||
|
|
||||||
|
Honest install badge — the flow list and analyzer only offer
|
||||||
|
"Install" for capabilities a configured store can actually resolve:
|
||||||
|
|
||||||
|
- **Store-resolvability split in the flow list.** Missing
|
||||||
|
capabilities are classified BEFORE any click: store-resolvable
|
||||||
|
ones keep the amber chip + Install link (which now installs only
|
||||||
|
those), the rest render a quiet "not in store" chip whose tooltip
|
||||||
|
explains the three recovery paths (local install via
|
||||||
|
`chain install --link`, adding the providing store, configuring
|
||||||
|
the MCP/n8n integration). Previously the Install action covered
|
||||||
|
every missing capability and could end in the hub's
|
||||||
|
"no store entry for '<name>'" error.
|
||||||
|
- New host contract: `_FileList` consumes the host's
|
||||||
|
`storeCapabilities` (already exposed on `FlowEditorPage`) for the
|
||||||
|
split; hosts should pass only capabilities the store can install
|
||||||
|
(installable status, native kind).
|
||||||
|
- **Analyzer not-in-store message** now names all three recovery
|
||||||
|
paths instead of only the local-install hint (EN + DE).
|
||||||
|
|
||||||
## 0.24.1
|
## 0.24.1
|
||||||
|
|
||||||
- No ALL-CAPS "FLOWS" panel header: the page toolbar already names
|
- No ALL-CAPS "FLOWS" panel header: the page toolbar already names
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ import 'tokens.dart';
|
||||||
import 'widgets.dart';
|
import 'widgets.dart';
|
||||||
import 'widgets/capability_picker.dart';
|
import 'widgets/capability_picker.dart';
|
||||||
import 'widgets/flow_canvas.dart';
|
import 'widgets/flow_canvas.dart';
|
||||||
|
import 'widgets/missing_modules_badge.dart';
|
||||||
import 'widgets/properties_panel.dart';
|
import 'widgets/properties_panel.dart';
|
||||||
import 'widgets/run_tab.dart';
|
import 'widgets/run_tab.dart';
|
||||||
|
|
||||||
|
|
@ -591,6 +592,7 @@ outputs:
|
||||||
installedNames: _installedNames(
|
installedNames: _installedNames(
|
||||||
widget.availableCapabilities,
|
widget.availableCapabilities,
|
||||||
),
|
),
|
||||||
|
storeNames: _installedNames(widget.storeCapabilities),
|
||||||
onOpen: _openFile,
|
onOpen: _openFile,
|
||||||
onRefresh: _refreshFiles,
|
onRefresh: _refreshFiles,
|
||||||
onStart: _startFile,
|
onStart: _startFile,
|
||||||
|
|
@ -1347,6 +1349,12 @@ class _FileList extends StatefulWidget {
|
||||||
/// Bare capability NAMES the host reports as installed. Used
|
/// Bare capability NAMES the host reports as installed. Used
|
||||||
/// to compute each row's missing-module count.
|
/// to compute each row's missing-module count.
|
||||||
final Set<String> installedNames;
|
final Set<String> installedNames;
|
||||||
|
|
||||||
|
/// Bare capability NAMES a configured store can install
|
||||||
|
/// (host-filtered to installable entries). Missing caps
|
||||||
|
/// outside this set render the "not in store" state instead
|
||||||
|
/// of an install action that the hub would refuse.
|
||||||
|
final Set<String> storeNames;
|
||||||
final void Function(_FlowFile) onOpen;
|
final void Function(_FlowFile) onOpen;
|
||||||
final VoidCallback onRefresh;
|
final VoidCallback onRefresh;
|
||||||
|
|
||||||
|
|
@ -1365,6 +1373,7 @@ class _FileList extends StatefulWidget {
|
||||||
required this.activeName,
|
required this.activeName,
|
||||||
required this.strings,
|
required this.strings,
|
||||||
required this.installedNames,
|
required this.installedNames,
|
||||||
|
required this.storeNames,
|
||||||
required this.onOpen,
|
required this.onOpen,
|
||||||
required this.onRefresh,
|
required this.onRefresh,
|
||||||
required this.onStart,
|
required this.onStart,
|
||||||
|
|
@ -1502,6 +1511,7 @@ class _FileListState extends State<_FileList> {
|
||||||
final f = files[i];
|
final f = files[i];
|
||||||
final isActive = f.name == widget.activeName;
|
final isActive = f.name == widget.activeName;
|
||||||
final missing = f.meta.missingCaps(widget.installedNames);
|
final missing = f.meta.missingCaps(widget.installedNames);
|
||||||
|
final split = splitMissingCaps(missing, widget.storeNames);
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () => widget.onOpen(f),
|
onTap: () => widget.onOpen(f),
|
||||||
child: Container(
|
child: Container(
|
||||||
|
|
@ -1579,13 +1589,20 @@ class _FileListState extends State<_FileList> {
|
||||||
if (f.meta.isExample)
|
if (f.meta.isExample)
|
||||||
_ExampleBadge(strings: strings),
|
_ExampleBadge(strings: strings),
|
||||||
if (missing.isNotEmpty)
|
if (missing.isNotEmpty)
|
||||||
_MissingModulesBadge(
|
MissingModulesBadge(
|
||||||
missing: missing,
|
installable: split.installable,
|
||||||
|
notInStore: split.notInStore,
|
||||||
strings: strings,
|
strings: strings,
|
||||||
onInstall: widget.onInstallMissing == null
|
// Install only what the store
|
||||||
|
// resolves — the not-in-store
|
||||||
|
// chip explains the rest.
|
||||||
|
onInstall:
|
||||||
|
widget.onInstallMissing == null ||
|
||||||
|
split.installable.isEmpty
|
||||||
? null
|
? null
|
||||||
: () =>
|
: () => widget.onInstallMissing!(
|
||||||
widget.onInstallMissing!(missing),
|
split.installable,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -1665,95 +1682,6 @@ class _ExampleBadge extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "N modules missing" status on flows whose steps reference
|
|
||||||
/// capabilities the hub doesn't provide. Status and action are
|
|
||||||
/// SEPARATE elements (usertest: the combined orange chip made
|
|
||||||
/// the whole list read like an error wall and ellipsized the
|
|
||||||
/// action word):
|
|
||||||
/// - status = quiet neutral chip with a small amber dot — a
|
|
||||||
/// note, not an alarm; warning orange stays reserved for real
|
|
||||||
/// failures.
|
|
||||||
/// - action = its own "Install" link that is never truncated;
|
|
||||||
/// both live in the row's Wrap, so tight widths wrap to a
|
|
||||||
/// second line instead of cutting text mid-word.
|
|
||||||
class _MissingModulesBadge extends StatelessWidget {
|
|
||||||
final List<String> missing;
|
|
||||||
final FlowEditorStrings strings;
|
|
||||||
final VoidCallback? onInstall;
|
|
||||||
const _MissingModulesBadge({
|
|
||||||
required this.missing,
|
|
||||||
required this.strings,
|
|
||||||
required this.onInstall,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
const dot = Color(0xFFEF6C00);
|
|
||||||
final fg = theme.colorScheme.onSurfaceVariant;
|
|
||||||
final chip = Tooltip(
|
|
||||||
message: strings.flowListNeedsModulesTooltip(missing.join('\n')),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
|
||||||
border: Border.all(
|
|
||||||
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.6),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: dot,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
strings.flowListNeedsModules(missing.length),
|
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
|
||||||
color: fg,
|
|
||||||
fontSize: 10,
|
|
||||||
letterSpacing: 0.2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (onInstall == null) return chip;
|
|
||||||
return Wrap(
|
|
||||||
spacing: FaiSpace.xs,
|
|
||||||
runSpacing: FaiSpace.xs,
|
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
|
||||||
children: [
|
|
||||||
chip,
|
|
||||||
InkWell(
|
|
||||||
onTap: onInstall,
|
|
||||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
|
||||||
child: Text(
|
|
||||||
strings.flowListInstallMissing,
|
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: 0.2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- empty state ---
|
// --- empty state ---
|
||||||
|
|
||||||
class _EmptyState extends StatelessWidget {
|
class _EmptyState extends StatelessWidget {
|
||||||
|
|
|
||||||
|
|
@ -259,11 +259,14 @@ class FlowEditorStrings {
|
||||||
);
|
);
|
||||||
String analyzerUnknownCapNotInStore(String cap) => _t(
|
String analyzerUnknownCapNotInStore(String cap) => _t(
|
||||||
'Unknown capability "$cap". '
|
'Unknown capability "$cap". '
|
||||||
'Not in the store — install locally with '
|
'No configured store can install it — install a local module '
|
||||||
'`chain install --link <path>` or check the spelling.',
|
'(`chain install --link <path>`), add the store that provides '
|
||||||
|
'it, or configure the integration (MCP/n8n) that supplies it.',
|
||||||
'Unbekannte Capability "$cap". '
|
'Unbekannte Capability "$cap". '
|
||||||
'Nicht im Store — lokal mit '
|
'Kein eingerichteter Store kann sie installieren — lokales '
|
||||||
'`chain install --link <pfad>` installieren oder Tippfehler prüfen.',
|
'Modul installieren (`chain install --link <pfad>`), den '
|
||||||
|
'passenden Store hinzufügen oder die Anbindung (MCP/n8n) '
|
||||||
|
'einrichten, die sie bereitstellt.',
|
||||||
);
|
);
|
||||||
String analyzerInputKind() => _t('input', 'Eingabe');
|
String analyzerInputKind() => _t('input', 'Eingabe');
|
||||||
String analyzerOutputKind() => _t('output', 'Ausgabe');
|
String analyzerOutputKind() => _t('output', 'Ausgabe');
|
||||||
|
|
@ -292,6 +295,24 @@ class FlowEditorStrings {
|
||||||
'Klicken, um die fehlenden zu installieren.',
|
'Klicken, um die fehlenden zu installieren.',
|
||||||
);
|
);
|
||||||
String get flowListInstallMissing => _t('Install', 'Installieren');
|
String get flowListInstallMissing => _t('Install', 'Installieren');
|
||||||
|
String flowListNotInStore(int n) => _t(
|
||||||
|
n == 1 ? 'not in store' : '$n not in store',
|
||||||
|
n == 1 ? 'nicht im Store' : '$n nicht im Store',
|
||||||
|
);
|
||||||
|
String flowListNotInStoreTooltip(String caps) => _t(
|
||||||
|
'This flow needs capabilities no configured store can install:\n'
|
||||||
|
'$caps\n'
|
||||||
|
'Ways to get them: install a local module '
|
||||||
|
'(`chain install --link <path>`), add the store that provides '
|
||||||
|
'them (Settings → Stores), or configure the integration '
|
||||||
|
'(MCP/n8n) that supplies the capability.',
|
||||||
|
'Dieser Flow braucht Capabilities, die kein eingerichteter Store '
|
||||||
|
'installieren kann:\n$caps\n'
|
||||||
|
'Wege: lokales Modul installieren '
|
||||||
|
'(`chain install --link <pfad>`), den passenden Store '
|
||||||
|
'hinzufügen (Einstellungen → Stores) oder die Anbindung '
|
||||||
|
'(MCP/n8n) einrichten, die die Capability bereitstellt.',
|
||||||
|
);
|
||||||
|
|
||||||
// Flow-list filter (first iteration: plain substring match).
|
// Flow-list filter (first iteration: plain substring match).
|
||||||
String get listFilterHint => _t('Filter flows…', 'Flows filtern…');
|
String get listFilterHint => _t('Filter flows…', 'Flows filtern…');
|
||||||
|
|
@ -387,8 +408,9 @@ class AnalyzerStrings {
|
||||||
'Unknown capability "$cap". Did you mean "$suggestion"?';
|
'Unknown capability "$cap". Did you mean "$suggestion"?';
|
||||||
static String _enUnknownCapNotInStore(String cap) =>
|
static String _enUnknownCapNotInStore(String cap) =>
|
||||||
'Unknown capability "$cap". '
|
'Unknown capability "$cap". '
|
||||||
'Not in the store — install locally with '
|
'No configured store can install it — install a local module '
|
||||||
'`chain install --link <path>` or check the spelling.';
|
'(`chain install --link <path>`), add the store that provides '
|
||||||
|
'it, or configure the integration (MCP/n8n) that supplies it.';
|
||||||
static String _enUnknownType(String kind, String value, String validList) =>
|
static String _enUnknownType(String kind, String value, String validList) =>
|
||||||
'Unknown $kind type "$value". Use one of: $validList.';
|
'Unknown $kind type "$value". Use one of: $validList.';
|
||||||
static String _enInputKind() => 'input';
|
static String _enInputKind() => 'input';
|
||||||
|
|
|
||||||
|
|
@ -73,8 +73,8 @@ class InstallCapabilityFix extends QuickFix {
|
||||||
/// (`chain install <url>`), then installs and reanalyzes.
|
/// (`chain install <url>`), then installs and reanalyzes.
|
||||||
///
|
///
|
||||||
/// This is the recovery path for private modules: the public
|
/// This is the recovery path for private modules: the public
|
||||||
/// store doesn't know about `htw.digiscout/onet-lookup`, but
|
/// store doesn't know about `acme.internal/directory-lookup`,
|
||||||
/// the operator can point the hub at the local clone.
|
/// but the operator can point the hub at the local clone.
|
||||||
@immutable
|
@immutable
|
||||||
class AddModuleSourceFix extends QuickFix {
|
class AddModuleSourceFix extends QuickFix {
|
||||||
/// The capability the operator wrote — the host uses it to
|
/// The capability the operator wrote — the host uses it to
|
||||||
|
|
|
||||||
154
lib/src/widgets/missing_modules_badge.dart
Normal file
154
lib/src/widgets/missing_modules_badge.dart
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
// Flow-list badge for flows whose steps reference capabilities
|
||||||
|
// the hub doesn't provide. Split out of flow_editor_page.dart so
|
||||||
|
// the resolvability states (installable from a store vs. not in
|
||||||
|
// any store) stay unit-testable.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../l10n.dart';
|
||||||
|
import '../tokens.dart';
|
||||||
|
|
||||||
|
/// Split a flow's missing capabilities into the ones a configured
|
||||||
|
/// store can actually install and the ones no store resolves.
|
||||||
|
///
|
||||||
|
/// [storeNames] is the host-supplied set of bare capability names
|
||||||
|
/// the store can install (already filtered to installable entries
|
||||||
|
/// — published/alpha, native). An empty set therefore means
|
||||||
|
/// "nothing is store-installable", not "unknown": the honest
|
||||||
|
/// state without store data is the not-in-store explanation, and
|
||||||
|
/// the local-install / add-store / configure-integration paths
|
||||||
|
/// remain available.
|
||||||
|
({List<String> installable, List<String> notInStore}) splitMissingCaps(
|
||||||
|
List<String> missing,
|
||||||
|
Set<String> storeNames,
|
||||||
|
) {
|
||||||
|
final installable = <String>[];
|
||||||
|
final notInStore = <String>[];
|
||||||
|
for (final cap in missing) {
|
||||||
|
final bare = cap.split('@').first;
|
||||||
|
(storeNames.contains(bare) ? installable : notInStore).add(cap);
|
||||||
|
}
|
||||||
|
return (installable: installable, notInStore: notInStore);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "N modules missing" status on flows whose steps reference
|
||||||
|
/// capabilities the hub doesn't provide. Status and action are
|
||||||
|
/// SEPARATE elements (usertest: the combined orange chip made
|
||||||
|
/// the whole list read like an error wall and ellipsized the
|
||||||
|
/// action word):
|
||||||
|
/// - status = quiet neutral chip with a small amber dot — a
|
||||||
|
/// note, not an alarm; warning orange stays reserved for real
|
||||||
|
/// failures.
|
||||||
|
/// - action = its own "Install" link that is never truncated;
|
||||||
|
/// both live in the row's Wrap, so tight widths wrap to a
|
||||||
|
/// second line instead of cutting text mid-word.
|
||||||
|
///
|
||||||
|
/// The install action only covers [installable] — capabilities a
|
||||||
|
/// configured store resolves. [notInStore] capabilities get their
|
||||||
|
/// own quiet chip whose tooltip explains the three recovery paths
|
||||||
|
/// (local install / add store / configure integration) BEFORE any
|
||||||
|
/// click, instead of an install button that would end in the
|
||||||
|
/// hub's "no store entry" error.
|
||||||
|
class MissingModulesBadge extends StatelessWidget {
|
||||||
|
final List<String> installable;
|
||||||
|
final List<String> notInStore;
|
||||||
|
final FlowEditorStrings strings;
|
||||||
|
final VoidCallback? onInstall;
|
||||||
|
const MissingModulesBadge({
|
||||||
|
super.key,
|
||||||
|
required this.installable,
|
||||||
|
required this.notInStore,
|
||||||
|
required this.strings,
|
||||||
|
required this.onInstall,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final children = <Widget>[
|
||||||
|
if (installable.isNotEmpty)
|
||||||
|
_chip(
|
||||||
|
theme,
|
||||||
|
dot: const Color(0xFFEF6C00),
|
||||||
|
label: strings.flowListNeedsModules(installable.length),
|
||||||
|
tooltip: strings.flowListNeedsModulesTooltip(
|
||||||
|
installable.join('\n'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (installable.isNotEmpty && onInstall != null)
|
||||||
|
InkWell(
|
||||||
|
onTap: onInstall,
|
||||||
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||||
|
child: Text(
|
||||||
|
strings.flowListInstallMissing,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (notInStore.isNotEmpty)
|
||||||
|
_chip(
|
||||||
|
theme,
|
||||||
|
// Muted dot: this state has no one-click fix, so it
|
||||||
|
// must not borrow the actionable chip's amber.
|
||||||
|
dot: theme.colorScheme.outline,
|
||||||
|
label: strings.flowListNotInStore(notInStore.length),
|
||||||
|
tooltip: strings.flowListNotInStoreTooltip(notInStore.join('\n')),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (children.length == 1) return children.single;
|
||||||
|
return Wrap(
|
||||||
|
spacing: FaiSpace.xs,
|
||||||
|
runSpacing: FaiSpace.xs,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: children,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _chip(
|
||||||
|
ThemeData theme, {
|
||||||
|
required Color dot,
|
||||||
|
required String label,
|
||||||
|
required String tooltip,
|
||||||
|
}) {
|
||||||
|
final fg = theme.colorScheme.onSurfaceVariant;
|
||||||
|
return Tooltip(
|
||||||
|
message: tooltip,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
decoration: BoxDecoration(color: dot, shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: fg,
|
||||||
|
fontSize: 10,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
name: chain_studio_flow_editor
|
name: chain_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.24.1
|
version: 0.25.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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -135,13 +135,20 @@ steps:
|
||||||
name: x
|
name: x
|
||||||
steps:
|
steps:
|
||||||
- id: c
|
- id: c
|
||||||
use: htw.private/secret@^0.1
|
use: acme.internal/secret@^0.1
|
||||||
'''));
|
'''));
|
||||||
expect(r.issues, hasLength(1));
|
expect(r.issues, hasLength(1));
|
||||||
final fixes = a.fixesFor(r.issues.first);
|
final fixes = a.fixesFor(r.issues.first);
|
||||||
expect(fixes, hasLength(1));
|
expect(fixes, hasLength(1));
|
||||||
expect(fixes.first, isA<AddModuleSourceFix>());
|
expect(fixes.first, isA<AddModuleSourceFix>());
|
||||||
expect(r.issues.first.message, contains('Not in the store'));
|
// The message must explain all three recovery paths in place:
|
||||||
|
// local install, adding a store, configuring an integration.
|
||||||
|
expect(
|
||||||
|
r.issues.first.message,
|
||||||
|
contains('No configured store can install it'),
|
||||||
|
);
|
||||||
|
expect(r.issues.first.message, contains('chain install --link'));
|
||||||
|
expect(r.issues.first.message, contains('integration'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('did-you-mean wins over install/add-source when a near miss exists',
|
test('did-you-mean wins over install/add-source when a near miss exists',
|
||||||
|
|
|
||||||
103
test/missing_modules_badge_test.dart
Normal file
103
test/missing_modules_badge_test.dart
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:chain_studio_flow_editor/src/l10n.dart';
|
||||||
|
import 'package:chain_studio_flow_editor/src/widgets/missing_modules_badge.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('splitMissingCaps', () {
|
||||||
|
test('separates store-resolvable from not-in-store capabilities', () {
|
||||||
|
final split = splitMissingCaps(
|
||||||
|
['text.extract', 'example-provider/tool.summarize'],
|
||||||
|
{'text.extract', 'debug.echo'},
|
||||||
|
);
|
||||||
|
expect(split.installable, ['text.extract']);
|
||||||
|
expect(split.notInStore, ['example-provider/tool.summarize']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matches on the bare name when the flow pins a version', () {
|
||||||
|
final split = splitMissingCaps(['text.extract@^0'], {'text.extract'});
|
||||||
|
expect(split.installable, ['text.extract@^0']);
|
||||||
|
expect(split.notInStore, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty store set classifies everything as not-in-store', () {
|
||||||
|
final split = splitMissingCaps(['text.extract'], {});
|
||||||
|
expect(split.installable, isEmpty);
|
||||||
|
expect(split.notInStore, ['text.extract']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> pumpBadge(
|
||||||
|
WidgetTester tester, {
|
||||||
|
required List<String> installable,
|
||||||
|
required List<String> notInStore,
|
||||||
|
VoidCallback? onInstall,
|
||||||
|
}) {
|
||||||
|
return tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: MissingModulesBadge(
|
||||||
|
installable: installable,
|
||||||
|
notInStore: notInStore,
|
||||||
|
strings: const FlowEditorStrings(FlowEditorLocale.en),
|
||||||
|
onInstall: onInstall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
group('MissingModulesBadge', () {
|
||||||
|
testWidgets('store-resolvable capability offers the install action', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
var installed = false;
|
||||||
|
await pumpBadge(
|
||||||
|
tester,
|
||||||
|
installable: ['text.extract'],
|
||||||
|
notInStore: [],
|
||||||
|
onInstall: () => installed = true,
|
||||||
|
);
|
||||||
|
expect(find.text('1 module missing'), findsOneWidget);
|
||||||
|
expect(find.text('Install'), findsOneWidget);
|
||||||
|
expect(find.textContaining('not in store'), findsNothing);
|
||||||
|
await tester.tap(find.text('Install'));
|
||||||
|
expect(installed, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'not-in-store capability shows the classified state, no install',
|
||||||
|
(tester) async {
|
||||||
|
await pumpBadge(
|
||||||
|
tester,
|
||||||
|
installable: [],
|
||||||
|
notInStore: ['example-provider/tool.summarize'],
|
||||||
|
onInstall: () => fail('no install action for not-in-store caps'),
|
||||||
|
);
|
||||||
|
expect(find.text('not in store'), findsOneWidget);
|
||||||
|
expect(find.text('Install'), findsNothing);
|
||||||
|
// The recovery paths are explained in place, before any click.
|
||||||
|
final tooltip = tester.widget<Tooltip>(find.byType(Tooltip));
|
||||||
|
expect(tooltip.message, contains('example-provider/tool.summarize'));
|
||||||
|
expect(tooltip.message, contains('chain install --link'));
|
||||||
|
expect(tooltip.message, contains('Settings → Stores'));
|
||||||
|
expect(tooltip.message, contains('integration'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('mixed state renders both chips, install covers store caps', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pumpBadge(
|
||||||
|
tester,
|
||||||
|
installable: ['text.extract'],
|
||||||
|
notInStore: ['example-provider/tool.summarize'],
|
||||||
|
onInstall: () {},
|
||||||
|
);
|
||||||
|
expect(find.text('1 module missing'), findsOneWidget);
|
||||||
|
expect(find.text('Install'), findsOneWidget);
|
||||||
|
expect(find.text('not in store'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue