Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
8 changes: 8 additions & 0 deletions example/screens/form_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ enum SelectionConfig<T extends FormConfigOption>
required this.defaultOptions,
this.requirements = const [],
this.multiSelect = false,
this.exclusiveOptions = const {},
this.selectionRequired = false,
this.description,
});

Expand All @@ -207,6 +209,12 @@ enum SelectionConfig<T extends FormConfigOption>
@override
final bool multiSelect;

@override
final Set<T> exclusiveOptions;

@override
final bool selectionRequired;

@override
final FormDescription? description;
}
Expand Down
12 changes: 12 additions & 0 deletions example/screens/multi_form_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ enum BoolConfig<T extends FormConfigOption> implements FormSelectionConfig<T> {
required this.defaultOptions,
this.requirements = const [],
this.multiSelect = false,
this.exclusiveOptions = const {},
this.selectionRequired = false,
this.description,
});

Expand All @@ -187,6 +189,10 @@ enum BoolConfig<T extends FormConfigOption> implements FormSelectionConfig<T> {
@override
final bool multiSelect;
@override
final Set<T> exclusiveOptions;
@override
final bool selectionRequired;
@override
final FormDescription? description;
}

Expand Down Expand Up @@ -242,6 +248,8 @@ enum SelectConfig<T extends FormConfigOption>
required this.defaultOptions,
this.requirements = const [],
this.multiSelect = false,
this.exclusiveOptions = const {},
this.selectionRequired = false,
this.description,
});

Expand All @@ -256,5 +264,9 @@ enum SelectConfig<T extends FormConfigOption>
@override
final bool multiSelect;
@override
final Set<T> exclusiveOptions;
@override
final bool selectionRequired;
@override
final FormDescription? description;
}
1 change: 1 addition & 0 deletions lib/src/components/form/multi_screen_form.dart
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ class _MultiScreenNavigationButtons extends StatelessComponent {
}
rebuild();
},
enabled: state.canAdvance,
focused: state.focusOnButton && state.focusedButtonIndex == 1,
),
const SizedBox(width: 1),
Expand Down
8 changes: 8 additions & 0 deletions lib/src/form/config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ abstract interface class FormSelectionConfig<T extends FormConfigOption>

/// 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<T> 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 {
Expand Down
23 changes: 19 additions & 4 deletions lib/src/form/state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
35 changes: 34 additions & 1 deletion test/form/form_state_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ enum TestConfig<T extends FormConfigOption> implements FormSelectionConfig<T> {
options: IdeOption.values,
multiSelect: true,
defaultOptions: <IdeOption>{},
exclusiveOptions: {IdeOption.none},
)
;

Expand All @@ -68,6 +69,8 @@ enum TestConfig<T extends FormConfigOption> implements FormSelectionConfig<T> {
required this.defaultOptions,
this.requirements = const [],
this.multiSelect = false,
this.exclusiveOptions = const {},
this.selectionRequired = false,
this.description,
});

Expand All @@ -86,6 +89,12 @@ enum TestConfig<T extends FormConfigOption> implements FormSelectionConfig<T> {
@override
final bool multiSelect;

@override
final Set<T> exclusiveOptions;

@override
final bool selectionRequired;

@override
final FormDescription? description;
}
Expand All @@ -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);
Expand Down Expand Up @@ -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',
() {
Expand Down
Loading
Loading