feat: project separation in the flow list (0.25.0)
Filter the file list by the host's active project — flows without a project: key count as 'general', display-only, the file is never rewritten. New flows are stamped with the active project's key (general and all-projects stay unstamped). Adds a toolbarTrailing slot so the host can mount its workspace switcher in the editor's single toolbar, and a flowsDir injection point so widget tests run against a temp dir instead of the operator's ~/.chain flows. Covered by pure filter-semantics tests plus hermetic widget tests for filtering, the project-empty state, and new-flow stamping. Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
parent
ab97e5e834
commit
c4a39a3779
6 changed files with 302 additions and 9 deletions
17
CHANGELOG.md
17
CHANGELOG.md
|
|
@ -21,6 +21,23 @@ Honest install badge — the flow list and analyzer only offer
|
||||||
- **Analyzer not-in-store message** now names all three recovery
|
- **Analyzer not-in-store message** now names all three recovery
|
||||||
paths instead of only the local-install hint (EN + DE).
|
paths instead of only the local-install hint (EN + DE).
|
||||||
|
|
||||||
|
Project separation in the flow list:
|
||||||
|
|
||||||
|
- **Workspace filter.** The file list filters by the host's
|
||||||
|
`activeProject`; flows without a `project:` key count as
|
||||||
|
`general` (display semantics — the file stays the truth and is
|
||||||
|
never rewritten). A project with no flows gets its own honest
|
||||||
|
empty state naming the project and the way out.
|
||||||
|
- **New flows are stamped** with the active project's `project:`
|
||||||
|
key (`general` and "all projects" stay unstamped — no key
|
||||||
|
already means general).
|
||||||
|
- **`toolbarTrailing` slot** on `FlowEditorPage`: the host can
|
||||||
|
place its workspace switcher in the editor's single toolbar.
|
||||||
|
- **`flowsDir` injection** on `FlowEditorPage`: tests (and other
|
||||||
|
hosts) point the editor at any directory instead of the
|
||||||
|
hard-wired `~/.chain/data/flows` — the new widget tests run
|
||||||
|
against a temp dir, never the operator's flows.
|
||||||
|
|
||||||
## 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
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,18 @@ class FlowEditorPage extends StatefulWidget {
|
||||||
/// (the mismatch note still shows — the file still wins).
|
/// (the mismatch note still shows — the file still wins).
|
||||||
final void Function(String fileProject)? onSwitchToFileProject;
|
final void Function(String fileProject)? onSwitchToFileProject;
|
||||||
|
|
||||||
|
/// Host-injected widget rendered in the editor toolbar, before
|
||||||
|
/// the New-flow button. Studio places its workspace (project)
|
||||||
|
/// switcher here — the editor keeps a single toolbar and stays
|
||||||
|
/// host-agnostic.
|
||||||
|
final Widget? toolbarTrailing;
|
||||||
|
|
||||||
|
/// Directory the editor lists/saves flow files in. `null` uses
|
||||||
|
/// the hub's default (`~/.chain/data/flows`). Tests inject a
|
||||||
|
/// temp dir so they never touch the operator's real flows
|
||||||
|
/// (hermetic per shared/TESTING.md).
|
||||||
|
final String? flowsDir;
|
||||||
|
|
||||||
const FlowEditorPage({
|
const FlowEditorPage({
|
||||||
super.key,
|
super.key,
|
||||||
this.initialFlowName,
|
this.initialFlowName,
|
||||||
|
|
@ -133,6 +145,8 @@ class FlowEditorPage extends StatefulWidget {
|
||||||
this.activeProject = '',
|
this.activeProject = '',
|
||||||
this.onSwitchToFileProject,
|
this.onSwitchToFileProject,
|
||||||
this.onPickFile,
|
this.onPickFile,
|
||||||
|
this.toolbarTrailing,
|
||||||
|
this.flowsDir,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -293,8 +307,10 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
||||||
|
|
||||||
// --- file ops ---
|
// --- file ops ---
|
||||||
|
|
||||||
|
String get _flowsDir => widget.flowsDir ?? _defaultFlowsDir();
|
||||||
|
|
||||||
Future<List<_FlowFile>> _listFiles() async {
|
Future<List<_FlowFile>> _listFiles() async {
|
||||||
final dir = Directory(_defaultFlowsDir());
|
final dir = Directory(_flowsDir);
|
||||||
if (!dir.existsSync()) return <_FlowFile>[];
|
if (!dir.existsSync()) return <_FlowFile>[];
|
||||||
final entries = await dir
|
final entries = await dir
|
||||||
.list()
|
.list()
|
||||||
|
|
@ -333,7 +349,7 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openByName(String name) async {
|
Future<void> _openByName(String name) async {
|
||||||
final path = '${_defaultFlowsDir()}/$name.yaml';
|
final path = '$_flowsDir/$name.yaml';
|
||||||
final file = File(path);
|
final file = File(path);
|
||||||
if (!file.existsSync()) return;
|
if (!file.existsSync()) return;
|
||||||
final text = await file.readAsString();
|
final text = await file.readAsString();
|
||||||
|
|
@ -376,7 +392,7 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
||||||
if (name == null) return;
|
if (name == null) return;
|
||||||
_controller.saving = true;
|
_controller.saving = true;
|
||||||
try {
|
try {
|
||||||
final file = File('${_defaultFlowsDir()}/$name.yaml');
|
final file = File('$_flowsDir/$name.yaml');
|
||||||
await file.writeAsString(
|
await file.writeAsString(
|
||||||
_controller.codeController.fullText,
|
_controller.codeController.fullText,
|
||||||
flush: true,
|
flush: true,
|
||||||
|
|
@ -420,11 +436,18 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
||||||
builder: (ctx) => _NewFlowDialog(strings: _l),
|
builder: (ctx) => _NewFlowDialog(strings: _l),
|
||||||
);
|
);
|
||||||
if (name == null || name.isEmpty || !mounted) return;
|
if (name == null || name.isEmpty || !mounted) return;
|
||||||
|
// Stamp the active workspace project into the new file so the
|
||||||
|
// flow stays visible under the filter it was created in. The
|
||||||
|
// file wins from here on; `general` (and "all projects") stay
|
||||||
|
// unstamped — no key already means general.
|
||||||
|
final project = widget.activeProject;
|
||||||
|
final projectLine =
|
||||||
|
project.isEmpty || project == 'general' ? '' : 'project: $project\n';
|
||||||
final template =
|
final template =
|
||||||
'''# ${_l.newTemplateComment(name)}
|
'''# ${_l.newTemplateComment(name)}
|
||||||
|
|
||||||
name: $name
|
name: $name
|
||||||
|
$projectLine
|
||||||
inputs:
|
inputs:
|
||||||
text:
|
text:
|
||||||
type: text
|
type: text
|
||||||
|
|
@ -439,7 +462,7 @@ outputs:
|
||||||
result: \$echo.echoed
|
result: \$echo.echoed
|
||||||
''';
|
''';
|
||||||
try {
|
try {
|
||||||
final dir = Directory(_defaultFlowsDir());
|
final dir = Directory(_flowsDir);
|
||||||
if (!dir.existsSync()) await dir.create(recursive: true);
|
if (!dir.existsSync()) await dir.create(recursive: true);
|
||||||
final file = File('${dir.path}/$name.yaml');
|
final file = File('${dir.path}/$name.yaml');
|
||||||
if (file.existsSync()) {
|
if (file.existsSync()) {
|
||||||
|
|
@ -577,6 +600,7 @@ outputs:
|
||||||
? () => Navigator.of(context).maybePop()
|
? () => Navigator.of(context).maybePop()
|
||||||
: null,
|
: null,
|
||||||
onNew: _newFlow,
|
onNew: _newFlow,
|
||||||
|
trailing: widget.toolbarTrailing,
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -593,6 +617,7 @@ outputs:
|
||||||
widget.availableCapabilities,
|
widget.availableCapabilities,
|
||||||
),
|
),
|
||||||
storeNames: _installedNames(widget.storeCapabilities),
|
storeNames: _installedNames(widget.storeCapabilities),
|
||||||
|
activeProject: widget.activeProject,
|
||||||
onOpen: _openFile,
|
onOpen: _openFile,
|
||||||
onRefresh: _refreshFiles,
|
onRefresh: _refreshFiles,
|
||||||
onStart: _startFile,
|
onStart: _startFile,
|
||||||
|
|
@ -946,12 +971,18 @@ class _Toolbar extends StatelessWidget {
|
||||||
final bool dirty;
|
final bool dirty;
|
||||||
final VoidCallback? onBack;
|
final VoidCallback? onBack;
|
||||||
final VoidCallback onNew;
|
final VoidCallback onNew;
|
||||||
|
|
||||||
|
/// Host-injected widget rendered before the New-flow button —
|
||||||
|
/// Studio places its workspace (project) switcher here so the
|
||||||
|
/// editor keeps a single toolbar and stays host-agnostic.
|
||||||
|
final Widget? trailing;
|
||||||
const _Toolbar({
|
const _Toolbar({
|
||||||
required this.strings,
|
required this.strings,
|
||||||
required this.activeName,
|
required this.activeName,
|
||||||
required this.dirty,
|
required this.dirty,
|
||||||
required this.onBack,
|
required this.onBack,
|
||||||
required this.onNew,
|
required this.onNew,
|
||||||
|
this.trailing,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -1020,6 +1051,10 @@ class _Toolbar extends StatelessWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
|
if (trailing != null) ...[
|
||||||
|
trailing!,
|
||||||
|
const SizedBox(width: FaiSpace.md),
|
||||||
|
],
|
||||||
FilledButton.tonalIcon(
|
FilledButton.tonalIcon(
|
||||||
onPressed: onNew,
|
onPressed: onNew,
|
||||||
icon: const Icon(Icons.add, size: 16),
|
icon: const Icon(Icons.add, size: 16),
|
||||||
|
|
@ -1257,7 +1292,16 @@ const String _sampleFlowMarker = 'F∆I sample flow';
|
||||||
class _FlowMeta {
|
class _FlowMeta {
|
||||||
final bool isExample;
|
final bool isExample;
|
||||||
final List<String> requiredCaps;
|
final List<String> requiredCaps;
|
||||||
const _FlowMeta({required this.isExample, required this.requiredCaps});
|
|
||||||
|
/// The file's own normalized `project:` slug; empty when the
|
||||||
|
/// YAML declares none (which counts as `general` for the list
|
||||||
|
/// filter — display semantics only, the file is never rewritten).
|
||||||
|
final String project;
|
||||||
|
const _FlowMeta({
|
||||||
|
required this.isExample,
|
||||||
|
required this.requiredCaps,
|
||||||
|
this.project = '',
|
||||||
|
});
|
||||||
|
|
||||||
static const empty = _FlowMeta(isExample: false, requiredCaps: []);
|
static const empty = _FlowMeta(isExample: false, requiredCaps: []);
|
||||||
|
|
||||||
|
|
@ -1321,7 +1365,11 @@ _FlowMeta _scanFlow(String text) {
|
||||||
final name = value.split('@').first.trim();
|
final name = value.split('@').first.trim();
|
||||||
if (name.isNotEmpty) caps.add(name);
|
if (name.isNotEmpty) caps.add(name);
|
||||||
}
|
}
|
||||||
return _FlowMeta(isExample: isExample, requiredCaps: caps.toList());
|
return _FlowMeta(
|
||||||
|
isExample: isExample,
|
||||||
|
requiredCaps: caps.toList(),
|
||||||
|
project: parseFlowProject(text),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reduce the host-supplied installed list (entries like
|
/// Reduce the host-supplied installed list (entries like
|
||||||
|
|
@ -1355,6 +1403,10 @@ class _FileList extends StatefulWidget {
|
||||||
/// outside this set render the "not in store" state instead
|
/// outside this set render the "not in store" state instead
|
||||||
/// of an install action that the hub would refuse.
|
/// of an install action that the hub would refuse.
|
||||||
final Set<String> storeNames;
|
final Set<String> storeNames;
|
||||||
|
|
||||||
|
/// Active workspace project slug; empty = all projects. Files
|
||||||
|
/// without a `project:` key count as `general`.
|
||||||
|
final String activeProject;
|
||||||
final void Function(_FlowFile) onOpen;
|
final void Function(_FlowFile) onOpen;
|
||||||
final VoidCallback onRefresh;
|
final VoidCallback onRefresh;
|
||||||
|
|
||||||
|
|
@ -1374,6 +1426,7 @@ class _FileList extends StatefulWidget {
|
||||||
required this.strings,
|
required this.strings,
|
||||||
required this.installedNames,
|
required this.installedNames,
|
||||||
required this.storeNames,
|
required this.storeNames,
|
||||||
|
required this.activeProject,
|
||||||
required this.onOpen,
|
required this.onOpen,
|
||||||
required this.onRefresh,
|
required this.onRefresh,
|
||||||
required this.onStart,
|
required this.onStart,
|
||||||
|
|
@ -1488,10 +1541,35 @@ class _FileListState extends State<_FileList> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Workspace filter first: files without a `project:` key
|
||||||
|
// count as `general` (display semantics — the file is the
|
||||||
|
// truth and never rewritten).
|
||||||
|
final inProject = all
|
||||||
|
.where(
|
||||||
|
(f) => flowVisibleInProject(
|
||||||
|
f.meta.project,
|
||||||
|
widget.activeProject,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
if (inProject.isEmpty) {
|
||||||
|
// Flows exist, just none in this project — say that and
|
||||||
|
// point at the way out (switch to all projects).
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(FaiSpace.md),
|
||||||
|
child: FaiEmptyState(
|
||||||
|
icon: Icons.folder_off_outlined,
|
||||||
|
title: strings.listProjectEmpty(widget.activeProject),
|
||||||
|
hint: strings.listProjectEmptyHint,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
final needle = _filter.toLowerCase();
|
final needle = _filter.toLowerCase();
|
||||||
final files = needle.isEmpty
|
final files = needle.isEmpty
|
||||||
? all
|
? inProject
|
||||||
: all.where((f) => f.name.toLowerCase().contains(needle)).toList();
|
: inProject
|
||||||
|
.where((f) => f.name.toLowerCase().contains(needle))
|
||||||
|
.toList();
|
||||||
if (files.isEmpty) {
|
if (files.isEmpty) {
|
||||||
// Flows exist, the filter just matches none — say that
|
// Flows exist, the filter just matches none — say that
|
||||||
// instead of pretending the directory is empty.
|
// instead of pretending the directory is empty.
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,21 @@ String parseFlowProject(String yaml) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The project a flow file effectively belongs to: its own
|
||||||
|
/// `project:` slug, or `general` when the file declares none.
|
||||||
|
/// Display/filter semantics only — the FILE stays the truth and
|
||||||
|
/// is never rewritten to make this explicit.
|
||||||
|
String effectiveFlowProject(String fileProject) =>
|
||||||
|
fileProject.isEmpty ? 'general' : fileProject;
|
||||||
|
|
||||||
|
/// Whether a flow file belongs in the list under the active
|
||||||
|
/// workspace filter. Empty [activeProject] = "all projects";
|
||||||
|
/// otherwise the file's effective project (no key = `general`)
|
||||||
|
/// must match.
|
||||||
|
bool flowVisibleInProject(String fileProject, String activeProject) =>
|
||||||
|
activeProject.isEmpty ||
|
||||||
|
effectiveFlowProject(fileProject) == activeProject;
|
||||||
|
|
||||||
/// Mirror of the hub's `normalize_project_slug`: lowercase,
|
/// Mirror of the hub's `normalize_project_slug`: lowercase,
|
||||||
/// collapse spaces/underscores/dashes to a single `-`, drop
|
/// collapse spaces/underscores/dashes to a single `-`, drop
|
||||||
/// everything else, trim trailing dashes. Empty in → empty out
|
/// everything else, trim trailing dashes. Empty in → empty out
|
||||||
|
|
|
||||||
|
|
@ -314,6 +314,19 @@ class FlowEditorStrings {
|
||||||
'(MCP/n8n) einrichten, die die Capability bereitstellt.',
|
'(MCP/n8n) einrichten, die die Capability bereitstellt.',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Project-scoped empty state: flows exist, none in the active
|
||||||
|
// workspace project.
|
||||||
|
String listProjectEmpty(String project) => _t(
|
||||||
|
'No flows in project "$project".',
|
||||||
|
'Keine Flows im Projekt „$project".',
|
||||||
|
);
|
||||||
|
String get listProjectEmptyHint => _t(
|
||||||
|
'New flow puts one here, or switch the project selector to '
|
||||||
|
'"All projects".',
|
||||||
|
'„Neuer Flow" legt hier einen an — oder wechseln Sie die '
|
||||||
|
'Projekt-Auswahl auf „Alle Projekte".',
|
||||||
|
);
|
||||||
|
|
||||||
// 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…');
|
||||||
String listFilterNoMatch(String query) => _t(
|
String listFilterNoMatch(String query) => _t(
|
||||||
|
|
|
||||||
146
test/flow_list_project_test.dart
Normal file
146
test/flow_list_project_test.dart
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
// Widget-level proof of the flow list's project separation:
|
||||||
|
// switching the active workspace filters the list, and a new flow
|
||||||
|
// is stamped with the active project's key. Hermetic — the editor
|
||||||
|
// gets a temp flows dir injected and never touches ~/.chain.
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:chain_studio_flow_editor/chain_studio_flow_editor.dart';
|
||||||
|
|
||||||
|
Future<void> _pumpEditor(
|
||||||
|
WidgetTester tester, {
|
||||||
|
required String flowsDir,
|
||||||
|
required String activeProject,
|
||||||
|
}) async {
|
||||||
|
// Build + let the real file IO of the listing complete inside
|
||||||
|
// runAsync (the fake-async test zone never drives real IO).
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: FlowEditorPage(
|
||||||
|
flowsDir: flowsDir,
|
||||||
|
activeProject: activeProject,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
});
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late Directory tmp;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
tmp = Directory.systemTemp.createTempSync('chain_editor_flows_test');
|
||||||
|
File('${tmp.path}/alpha-report.yaml').writeAsStringSync(
|
||||||
|
'name: alpha-report\nproject: client-a\nsteps: []\n',
|
||||||
|
);
|
||||||
|
File('${tmp.path}/plain.yaml').writeAsStringSync(
|
||||||
|
'name: plain\nsteps: []\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
tmp.deleteSync(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('active project filters the flow list', (tester) async {
|
||||||
|
await _pumpEditor(
|
||||||
|
tester,
|
||||||
|
flowsDir: tmp.path,
|
||||||
|
activeProject: 'client-a',
|
||||||
|
);
|
||||||
|
expect(find.text('alpha-report'), findsOneWidget);
|
||||||
|
expect(find.text('plain'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('general shows keyless flows only', (tester) async {
|
||||||
|
await _pumpEditor(
|
||||||
|
tester,
|
||||||
|
flowsDir: tmp.path,
|
||||||
|
activeProject: 'general',
|
||||||
|
);
|
||||||
|
expect(find.text('plain'), findsOneWidget);
|
||||||
|
expect(find.text('alpha-report'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('all projects shows everything', (tester) async {
|
||||||
|
await _pumpEditor(tester, flowsDir: tmp.path, activeProject: '');
|
||||||
|
expect(find.text('plain'), findsOneWidget);
|
||||||
|
expect(find.text('alpha-report'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('empty project state names the project and the way out', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pumpEditor(
|
||||||
|
tester,
|
||||||
|
flowsDir: tmp.path,
|
||||||
|
activeProject: 'client-b',
|
||||||
|
);
|
||||||
|
expect(find.textContaining('client-b'), findsOneWidget);
|
||||||
|
expect(find.text('alpha-report'), findsNothing);
|
||||||
|
expect(find.text('plain'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('new flow is stamped with the active project key', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pumpEditor(
|
||||||
|
tester,
|
||||||
|
flowsDir: tmp.path,
|
||||||
|
activeProject: 'client-a',
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('New flow'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byType(AlertDialog),
|
||||||
|
matching: find.byType(TextField),
|
||||||
|
),
|
||||||
|
'fresh-flow',
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('Create'));
|
||||||
|
await tester.runAsync(() => Future<void>.delayed(
|
||||||
|
const Duration(milliseconds: 50),
|
||||||
|
));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final created = File('${tmp.path}/fresh-flow.yaml');
|
||||||
|
expect(created.existsSync(), isTrue);
|
||||||
|
expect(created.readAsStringSync(), contains('project: client-a'));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('new flow under general stays unstamped (no key = general)', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pumpEditor(
|
||||||
|
tester,
|
||||||
|
flowsDir: tmp.path,
|
||||||
|
activeProject: 'general',
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('New flow'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byType(AlertDialog),
|
||||||
|
matching: find.byType(TextField),
|
||||||
|
),
|
||||||
|
'general-flow',
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('Create'));
|
||||||
|
await tester.runAsync(() => Future<void>.delayed(
|
||||||
|
const Duration(milliseconds: 50),
|
||||||
|
));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final created = File('${tmp.path}/general-flow.yaml');
|
||||||
|
expect(created.existsSync(), isTrue);
|
||||||
|
expect(created.readAsStringSync(), isNot(contains('project:')));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,30 @@ void main() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('project filter semantics', () {
|
||||||
|
test('no project key counts as general (display only)', () {
|
||||||
|
expect(effectiveFlowProject(''), 'general');
|
||||||
|
expect(effectiveFlowProject('client-a'), 'client-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty active workspace shows every flow', () {
|
||||||
|
expect(flowVisibleInProject('', ''), isTrue);
|
||||||
|
expect(flowVisibleInProject('client-a', ''), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('general shows keyless flows and explicit general ones', () {
|
||||||
|
expect(flowVisibleInProject('', 'general'), isTrue);
|
||||||
|
expect(flowVisibleInProject('general', 'general'), isTrue);
|
||||||
|
expect(flowVisibleInProject('client-a', 'general'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a project shows only its own flows', () {
|
||||||
|
expect(flowVisibleInProject('client-a', 'client-a'), isTrue);
|
||||||
|
expect(flowVisibleInProject('', 'client-a'), isFalse);
|
||||||
|
expect(flowVisibleInProject('client-b', 'client-a'), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('normalizeProjectSlug', () {
|
group('normalizeProjectSlug', () {
|
||||||
test('collapses separators and trims trailing dashes', () {
|
test('collapses separators and trims trailing dashes', () {
|
||||||
expect(normalizeProjectSlug('Projekt Alpha__'), 'projekt-alpha');
|
expect(normalizeProjectSlug('Projekt Alpha__'), 'projekt-alpha');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue