diff --git a/CHANGELOG.md b/CHANGELOG.md index a00f9ef..d1af0ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.13.0 + +- **FEAT**: Multi screen forms can now require a selection before moving past a config (`selectionRequired`), and multi-select configs can mark options that can not be combined with others (`exclusiveOptions`). +- **FEAT**: Added `MultiScreenFormState.canAdvance`. The `Next`/`Submit` button is disabled while the current screen is missing a required selection. +- **BREAKING**: `FormSelectionConfig` implementers must add `exclusiveOptions` and `selectionRequired`. + ## 0.12.0 - **FEAT**: The tab bar underline now matches the surrounding box border's color (the selection highlight keeps its accent) and merges into the border with junction characters (`┝`/`┥`) instead of leaving gaps. diff --git a/example/screens/form_screen.dart b/example/screens/form_screen.dart index 2d212b6..10257ef 100644 --- a/example/screens/form_screen.dart +++ b/example/screens/form_screen.dart @@ -189,6 +189,8 @@ enum SelectionConfig required this.defaultOptions, this.requirements = const [], this.multiSelect = false, + this.exclusiveOptions = const {}, + this.selectionRequired = false, this.description, }); @@ -207,6 +209,12 @@ enum SelectionConfig @override final bool multiSelect; + @override + final Set exclusiveOptions; + + @override + final bool selectionRequired; + @override final FormDescription? description; } diff --git a/example/screens/multi_form_screen.dart b/example/screens/multi_form_screen.dart index adce0af..5b00811 100644 --- a/example/screens/multi_form_screen.dart +++ b/example/screens/multi_form_screen.dart @@ -173,6 +173,8 @@ enum BoolConfig implements FormSelectionConfig { required this.defaultOptions, this.requirements = const [], this.multiSelect = false, + this.exclusiveOptions = const {}, + this.selectionRequired = false, this.description, }); @@ -187,6 +189,10 @@ enum BoolConfig implements FormSelectionConfig { @override final bool multiSelect; @override + final Set exclusiveOptions; + @override + final bool selectionRequired; + @override final FormDescription? description; } @@ -242,6 +248,8 @@ enum SelectConfig required this.defaultOptions, this.requirements = const [], this.multiSelect = false, + this.exclusiveOptions = const {}, + this.selectionRequired = false, this.description, }); @@ -256,5 +264,9 @@ enum SelectConfig @override final bool multiSelect; @override + final Set exclusiveOptions; + @override + final bool selectionRequired; + @override final FormDescription? description; } diff --git a/lib/src/components/form/multi_screen_form.dart b/lib/src/components/form/multi_screen_form.dart index 749b1cf..03e8f60 100644 --- a/lib/src/components/form/multi_screen_form.dart +++ b/lib/src/components/form/multi_screen_form.dart @@ -155,6 +155,7 @@ class _MultiScreenNavigationButtons extends StatelessComponent { } rebuild(); }, + enabled: state.canAdvance, focused: state.focusOnButton && state.focusedButtonIndex == 1, ), const SizedBox(width: 1), diff --git a/lib/src/form/config.dart b/lib/src/form/config.dart index 50c3463..eb897e1 100644 --- a/lib/src/form/config.dart +++ b/lib/src/form/config.dart @@ -51,6 +51,14 @@ abstract interface class FormSelectionConfig /// Whether this config supports multi-select options. bool get multiSelect; + + /// Options that can not be combined with any other option + /// when [multiSelect] is true. + Set get exclusiveOptions; + + /// Whether at least one option must be selected + /// before a form can move past this config. + bool get selectionRequired; } extension FormSelectionConfigExtension on FormSelectionConfig { diff --git a/lib/src/form/state.dart b/lib/src/form/state.dart index acc6e4a..e8717ea 100644 --- a/lib/src/form/state.dart +++ b/lib/src/form/state.dart @@ -89,11 +89,16 @@ class FormState { FormConfigOption option, ) { if (config.multiSelect) { - final selections = _selectionState[config]; - if (selections != null && selections.contains(option)) { + final selections = _selectionState[config] ?? {}; + if (selections.contains(option)) { _selectionState[config] = selections.difference({option}); + } else if (config.exclusiveOptions.contains(option)) { + _selectionState[config] = {option}; } else { - _selectionState[config] = {...?selections, option}; + _selectionState[config] = { + ...selections.difference(config.exclusiveOptions), + option, + }; } } else { _selectionState[config] = {option}; @@ -253,6 +258,16 @@ class MultiScreenFormState extends FormState { /// Whether the current screen is the summary screen. bool get isSummary => _currentScreenIndex >= configScreenCount; + /// False while the current screen is missing a required selection. + bool get canAdvance { + if (isSummary) return true; + final config = configurations[_currentScreenIndex]; + if (config is! FormSelectionConfig || !config.selectionRequired) { + return true; + } + return getSelectedOptionsFor(config)?.isNotEmpty ?? false; + } + bool _focusOnButton = false; /// Whether the Back/Next buttons are focused in multi-screen mode. @@ -350,7 +365,7 @@ class MultiScreenFormState extends FormState { /// Advances to the next screen in multi-screen mode. void nextScreen() { - if (hasSingleScreen) return; + if (hasSingleScreen || !canAdvance) return; if (_currentScreenIndex < configScreenCount) { _currentScreenIndex++; _updateFormFocus(); diff --git a/pubspec.yaml b/pubspec.yaml index 88de60a..faffe6f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: serverpod_tui description: A collection of tools for building terminal user interfaces. -version: 0.12.0 +version: 0.13.0 repository: https://github.com/serverpod/serverpod_tui homepage: https://serverpod.dev issue_tracker: https://github.com/serverpod/serverpod_tui/issues diff --git a/test/form/form_state_test.dart b/test/form/form_state_test.dart index a3358f2..f1c09fc 100644 --- a/test/form/form_state_test.dart +++ b/test/form/form_state_test.dart @@ -59,6 +59,7 @@ enum TestConfig implements FormSelectionConfig { options: IdeOption.values, multiSelect: true, defaultOptions: {}, + exclusiveOptions: {IdeOption.none}, ) ; @@ -68,6 +69,8 @@ enum TestConfig implements FormSelectionConfig { required this.defaultOptions, this.requirements = const [], this.multiSelect = false, + this.exclusiveOptions = const {}, + this.selectionRequired = false, this.description, }); @@ -86,6 +89,12 @@ enum TestConfig implements FormSelectionConfig { @override final bool multiSelect; + @override + final Set exclusiveOptions; + + @override + final bool selectionRequired; + @override final FormDescription? description; } @@ -108,7 +117,8 @@ enum IdeOption implements FormConfigOption { claude('Claude'), cursor('Cursor'), openCode('OpenCode'), - vsCode('VS Code') + vsCode('VS Code'), + none('None') ; const IdeOption(this.label); @@ -355,6 +365,29 @@ void main() { }, ); + test( + 'when an exclusive multi-select option is selected after other options, ' + 'then only the exclusive option is selected', + () { + state.updateSelectedOption(TestConfig.ide, IdeOption.vsCode); + state.updateSelectedOption(TestConfig.ide, IdeOption.cursor); + state.updateSelectedOption(TestConfig.ide, IdeOption.none); + + expect(state.getSelectedOptionsFor(TestConfig.ide), {IdeOption.none}); + }, + ); + + test( + 'when another option is selected after an exclusive multi-select option, ' + 'then the exclusive option is deselected', + () { + state.updateSelectedOption(TestConfig.ide, IdeOption.none); + state.updateSelectedOption(TestConfig.ide, IdeOption.cursor); + + expect(state.getSelectedOptionsFor(TestConfig.ide), {IdeOption.cursor}); + }, + ); + test( 'then isOptionSelectedForConfig returns true for option that is selected for a config', () { diff --git a/test/form/multi_screen_test.dart b/test/form/multi_screen_test.dart index fafe705..0cfc72e 100644 --- a/test/form/multi_screen_test.dart +++ b/test/form/multi_screen_test.dart @@ -36,6 +36,8 @@ enum SimpleConfig required this.defaultOptions, this.requirements = const [], this.multiSelect = false, + this.exclusiveOptions = const {}, + this.selectionRequired = false, this.description, }); @@ -50,9 +52,44 @@ enum SimpleConfig @override final bool multiSelect; @override + final Set exclusiveOptions; + @override + final bool selectionRequired; + @override final FormDescription? description; } +enum EditorOption implements FormConfigOption { + vsCode('VS Code'), + cursor('Cursor') + ; + + const EditorOption(this.label); + @override + final String label; +} + +class RequiredEditorConfig implements FormSelectionConfig { + const RequiredEditorConfig(); + + @override + String get label => 'Editors'; + @override + List get options => EditorOption.values; + @override + Set get defaultOptions => const {}; + @override + bool get multiSelect => true; + @override + bool get selectionRequired => true; + @override + Set get exclusiveOptions => const {}; + @override + List get requirements => const []; + @override + FormDescription? get description => null; +} + // --- Test app infrastructure --- class _MultiScreenTestState extends TuiState { @@ -574,4 +611,131 @@ void main() { }, ); }); + + group( + 'Given a multi-screen form whose first config requires a selection', + () { + late NoctermTester tester; + late MultiScreenFormState state; + late _MultiScreenTestHolder holder; + + setUp(() async { + state = MultiScreenFormState([ + const RequiredEditorConfig(), + SimpleConfig.database, + ]); + holder = _MultiScreenTestHolder(_MultiScreenTestState(state)); + tester = await NoctermTester.create(size: const Size(80, 24)); + await tester.pumpComponent( + _MultiScreenTestApp(holder: holder), + ); + }); + + tearDown(() async { + tester.dispose(); + await holder.dispose(); + }); + + test( + 'when Enter is pressed without a selection, ' + 'then it stays on the first screen', + () async { + await _sendKey(tester, LogicalKey.enter); + await _pump(tester); + + expect(state.currentScreenIndex, 0); + }, + ); + + test( + 'when the Next button is focused and activated using Space key ' + 'without a selection, ' + 'then it stays on the first screen', + () async { + await _sendKey(tester, LogicalKey.arrowDown); + await _pump(tester); + expect(state.focusOnButton, isTrue); + + await _sendKey(tester, LogicalKey.space); + await _pump(tester); + + expect(state.currentScreenIndex, 0); + }, + ); + + test( + 'when an option is selected using Space key and Enter is pressed, ' + 'then it advances to the next screen', + () async { + await _sendKey(tester, LogicalKey.space); + await _pump(tester); + + await _sendKey(tester, LogicalKey.enter); + await _pump(tester); + + expect(state.currentScreenIndex, 1); + }, + ); + }, + ); + + group( + 'Given a multi-screen form with a single config that requires a selection ' + 'and onSubmit', + () { + late NoctermTester tester; + late MultiScreenFormState state; + late _MultiScreenTestHolder holder; + var onSubmitCalled = false; + + setUp(() async { + onSubmitCalled = false; + state = MultiScreenFormState([const RequiredEditorConfig()]); + holder = _MultiScreenTestHolder(_MultiScreenTestState(state)); + tester = await NoctermTester.create(size: const Size(80, 24)); + await tester.pumpComponent( + _MultiScreenTestApp( + holder: holder, + onSubmit: () => onSubmitCalled = true, + ), + ); + }); + + tearDown(() async { + tester.dispose(); + await holder.dispose(); + }); + + test( + 'when Space activates the submit button without a selection, ' + 'then onSubmit is not called', + () async { + await _sendKey(tester, LogicalKey.arrowDown); + await _pump(tester); + expect(state.focusOnButton, isTrue); + + await _sendKey(tester, LogicalKey.space); + await _pump(tester); + + expect(onSubmitCalled, isFalse); + }, + ); + + test( + 'when Space activates the submit button after selecting an option, ' + 'then onSubmit is called', + () async { + await _sendKey(tester, LogicalKey.space); + await _pump(tester); + + await _sendKey(tester, LogicalKey.arrowDown); + await _pump(tester); + await _sendKey(tester, LogicalKey.space); + await _pump(tester); + + expect(onSubmitCalled, isTrue); + }, + ); + }, + ); }