Skip to content

[#15] Added option groups, separators and disabled options.#27

Open
AlexSkrypnyk wants to merge 8 commits into
mainfrom
feature/15-option-groups
Open

[#15] Added option groups, separators and disabled options.#27
AlexSkrypnyk wants to merge 8 commits into
mainfrom
feature/15-option-groups

Conversation

@AlexSkrypnyk

@AlexSkrypnyk AlexSkrypnyk commented Jul 10, 2026

Copy link
Copy Markdown
Member

Closes #15

Summary

Adds group headings, non-selectable separators, and disabled options (with an optional reason) to the choice widgets (select, multiselect, search, and multisearch by inheritance). Non-selectable rows are visual only: navigation skips them, they can never be highlighted or selected, they never appear in the collected value, and they are excluded from the JSON schema. Headless collection rejects a supplied disabled - or otherwise unknown - option value with a clear, value-naming error.

Changes

Option model (src/Config/)

  • New OptionKind enum (Option, Separator, Heading).
  • Option gains kind, disabled, disabledReason, a selectable() helper, and a static Option::list() normalizer that accepts either the ['value' => 'label'] map shorthand or a list<Option>.
  • Field::$options is now an ordered list<Option>; adds selectableValues() and optionError() (rejects a non-array multi value, a disabled value, or an unknown value, naming the offending item). FieldType gains constrainsToOptions() and isMulti().

Builder (src/Builder/FieldBuilder.php)

  • option() gains disabled and disabled_reason parameters, and repeated option values dedupe in place.
  • New separator() and heading() methods add the non-selectable rows.

Widgets (src/Widget/)

  • New ChoiceListTrait centralizes selectable-skipping navigation and heading/separator/disabled rendering.
  • SelectWidget, SearchWidget and MultiSelectWidget (plus MultiSearchWidget by inheritance) take list<Option>, skip non-selectable rows, and refuse to select a disabled option; a filter shows only options (headings and separators drop away). SuggestWidget receives selectable values only.

Theme (src/Theme/)

  • New heading(), divider() and disabled() atoms on ThemeInterface and DefaultTheme, so every kind is theme-driven.

Headless (src/Engine/, src/Schema/)

  • Engine::collect() and SchemaValidator reject a disabled/unknown supplied value through the shared Field::optionError(); SchemaGenerator emits selectable options only.

Docs, playground and screenshots

  • README "Option groups, separators and disabled options" section, with two four-variant screenshot tables (a grouped select and a grouped multiselect) rendered through the project's existing asset generator.
  • Runnable widget-select-groups.php and widget-multiselect-groups.php playground demos, registered as generator jobs.

Tests

  • New OptionTest, a shared MixedOptionsTrait, and cases across the widget, engine, schema, theme and builder suites. The full suite passes (714 tests) and lint is clean.

Before / After

Before: a flat, uniformly-selectable list      After: group headings, separators and disabled rows

  > ( ) Standard                                  Recommended                      <- heading   (skipped)
    ( ) Minimal                                 > (o) Standard
    ( ) Demo Umami                                ( ) Minimal
                                                  --------------------------       <- separator (skipped)
  every row is selectable                         ( ) Demo Umami (requires 8.4)    <- disabled  (skipped)

                                                  the cursor lands only on selectable rows;
                                                  disabled and structural rows are never collected
                                                  and never appear in the JSON schema

@AlexSkrypnyk AlexSkrypnyk added the A4 Board worker 4 label Jul 10, 2026
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Choice lists now support headings, separators, disabled options, disabled reasons, selectable-value validation, filtered schemas, theme-driven rendering, and navigation that skips non-selectable rows across select, search, and multiselect widgets.

Changes

Choice-list option support

Layer / File(s) Summary
Option model and field configuration
src/Config/*, src/Builder/FieldBuilder.php, tests/phpunit/Unit/Config/OptionTest.php, tests/phpunit/Unit/Builder/FormTest.php, README.md
Option rows support headings, separators, disabled metadata, ordered normalization, selectable-value extraction, and standardized option errors.
Validation and schema filtering
src/Engine/Engine.php, src/Schema/*, tests/phpunit/Unit/Engine/EngineTest.php, tests/phpunit/Unit/Schema/*
Collection and schema validation reject disabled or unknown values, while generated schemas include selectable options only.
Themed rows and shared navigation
src/Theme/*, src/Widget/ChoiceListTrait.php, tests/phpunit/Unit/Theme/ThemeTest.php
Theme APIs and shared widget helpers render headings, dividers, and disabled labels while navigating selectable rows.
Choice widgets, factory wiring, and demos
src/Widget/*, tests/phpunit/Unit/Widget/*, tests/phpunit/Traits/*, playground/3-widgets/*, docs/util/update-assets.php, playground/README.md
Choice widgets consume typed option rows, skip non-selectable entries during navigation and selection, render mixed rows, receive options directly from fields, and include grouped-option demos.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant FieldBuilder
  participant Field
  participant WidgetFactory
  participant ChoiceWidget
  participant Theme
  FieldBuilder->>Field: build ordered option rows
  Field->>WidgetFactory: provide options and selectable values
  WidgetFactory->>ChoiceWidget: initialize widget options
  ChoiceWidget->>Theme: render headings, separators, and disabled labels
  ChoiceWidget-->>Field: return selectable answer values
Loading

Possibly related PRs

  • drevops/tui#3: Related themed rendering and shared radio-row behavior.
  • drevops/tui#21: Related theme interface and default-theme rendering contract updates.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.90% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes implement grouped headings, separators, disabled options, navigation skipping, schema filtering, docs, and tests as required.
Out of Scope Changes check ✅ Passed No clearly unrelated code changes are evident; the added playground demos and docs support the requested option-list feature.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding option groups, separators, and disabled options.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/15-option-groups

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/Engine/Engine.php`:
- Around line 119-123: Reject non-array values for multi-choice fields before
validation: in the Engine validation logic around optionError(), detect
MultiSelect/MultiSearch fields whose value is not an array and throw
EngineException; alternatively update Field::optionError() to return an error
for non-array multi values instead of treating them as empty.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 8185b839-0c4e-4d3b-992b-0d484d19306c

📥 Commits

Reviewing files that changed from the base of the PR and between 6486a4c and b06face.

📒 Files selected for processing (28)
  • README.md
  • src/Builder/FieldBuilder.php
  • src/Config/Field.php
  • src/Config/FieldType.php
  • src/Config/Option.php
  • src/Config/OptionKind.php
  • src/Engine/Engine.php
  • src/Schema/SchemaGenerator.php
  • src/Schema/SchemaValidator.php
  • src/Theme/DefaultTheme.php
  • src/Theme/ThemeInterface.php
  • src/Widget/ChoiceListTrait.php
  • src/Widget/MultiSelectWidget.php
  • src/Widget/SearchWidget.php
  • src/Widget/SelectWidget.php
  • src/Widget/WidgetFactory.php
  • tests/phpunit/Traits/MixedOptionsTrait.php
  • tests/phpunit/Unit/Builder/FormTest.php
  • tests/phpunit/Unit/Config/OptionTest.php
  • tests/phpunit/Unit/Engine/EngineTest.php
  • tests/phpunit/Unit/Schema/SchemaGeneratorTest.php
  • tests/phpunit/Unit/Schema/SchemaValidatorTest.php
  • tests/phpunit/Unit/Theme/ThemeTest.php
  • tests/phpunit/Unit/Widget/MultiSearchWidgetTest.php
  • tests/phpunit/Unit/Widget/MultiSelectWidgetTest.php
  • tests/phpunit/Unit/Widget/SearchWidgetTest.php
  • tests/phpunit/Unit/Widget/SelectWidgetTest.php
  • tests/phpunit/Unit/Widget/WidgetFactoryTest.php

Comment thread src/Engine/Engine.php
@AlexSkrypnyk AlexSkrypnyk force-pushed the feature/15-option-groups branch from b06face to 8f376df Compare July 10, 2026 11:25
@github-actions

This comment has been minimized.

@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.51%. Comparing base (1b768e2) to head (179d09b).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #27      +/-   ##
==========================================
+ Coverage   99.39%   99.51%   +0.12%     
==========================================
  Files          68       70       +2     
  Lines        1977     2082     +105     
==========================================
+ Hits         1965     2072     +107     
+ Misses         12       10       -2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@AlexSkrypnyk AlexSkrypnyk force-pushed the feature/15-option-groups branch from a29f0af to 698b972 Compare July 10, 2026 11:40
@github-actions

This comment has been minimized.

@AlexSkrypnyk AlexSkrypnyk force-pushed the feature/15-option-groups branch from 698b972 to ff56d7c Compare July 10, 2026 12:05
@github-actions

This comment has been minimized.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/Config/Field.php`:
- Around line 145-169: Update Field::optionError to explicitly reject non-scalar
items in multi-select values with a clear malformed-item error before calling
scalarOptionError; do not coerce arrays or objects to an empty string. Preserve
the existing scalar validation and return the first relevant error.

In `@src/Widget/SelectWidget.php`:
- Around line 93-112: Extract the shared heading/separator/disabled dispatch
from SelectWidget::view(), SearchWidget::view(), and MultiSelectWidget::view()
into a protected renderRows() helper in ChoiceListTrait. Have it accept the
theme, rows, and a closure for normal leaf rendering, preserving cursor and
disabled arguments, then update all three views to use the helper while
retaining their widget-specific leaf renderers.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 9ebfb611-47ea-4eee-8756-86b726325c77

📥 Commits

Reviewing files that changed from the base of the PR and between b06face and ff56d7c.

⛔ Files ignored due to path filters (8)
  • docs/assets/widget-multiselect-groups-ascii-no-ansi.svg is excluded by !**/*.svg
  • docs/assets/widget-multiselect-groups-ascii.svg is excluded by !**/*.svg
  • docs/assets/widget-multiselect-groups-no-ansi.svg is excluded by !**/*.svg
  • docs/assets/widget-multiselect-groups.svg is excluded by !**/*.svg
  • docs/assets/widget-select-groups-ascii-no-ansi.svg is excluded by !**/*.svg
  • docs/assets/widget-select-groups-ascii.svg is excluded by !**/*.svg
  • docs/assets/widget-select-groups-no-ansi.svg is excluded by !**/*.svg
  • docs/assets/widget-select-groups.svg is excluded by !**/*.svg
📒 Files selected for processing (32)
  • README.md
  • docs/util/update-assets.php
  • playground/3-widgets/widget-multiselect-groups.php
  • playground/3-widgets/widget-select-groups.php
  • playground/README.md
  • src/Builder/FieldBuilder.php
  • src/Config/Field.php
  • src/Config/FieldType.php
  • src/Config/Option.php
  • src/Config/OptionKind.php
  • src/Engine/Engine.php
  • src/Schema/SchemaGenerator.php
  • src/Schema/SchemaValidator.php
  • src/Theme/DefaultTheme.php
  • src/Theme/ThemeInterface.php
  • src/Widget/ChoiceListTrait.php
  • src/Widget/MultiSelectWidget.php
  • src/Widget/SearchWidget.php
  • src/Widget/SelectWidget.php
  • src/Widget/WidgetFactory.php
  • tests/phpunit/Traits/MixedOptionsTrait.php
  • tests/phpunit/Unit/Builder/FormTest.php
  • tests/phpunit/Unit/Config/OptionTest.php
  • tests/phpunit/Unit/Engine/EngineTest.php
  • tests/phpunit/Unit/Schema/SchemaGeneratorTest.php
  • tests/phpunit/Unit/Schema/SchemaValidatorTest.php
  • tests/phpunit/Unit/Theme/ThemeTest.php
  • tests/phpunit/Unit/Widget/MultiSearchWidgetTest.php
  • tests/phpunit/Unit/Widget/MultiSelectWidgetTest.php
  • tests/phpunit/Unit/Widget/SearchWidgetTest.php
  • tests/phpunit/Unit/Widget/SelectWidgetTest.php
  • tests/phpunit/Unit/Widget/WidgetFactoryTest.php

Comment thread src/Config/Field.php
Comment on lines +145 to +169
public function optionError(mixed $value): ?string {
if (!$this->type->constrainsToOptions() || $this->options === []) {
return NULL;
}

if ($this->type->isMulti()) {
if (!is_array($value)) {
return 'value must be a list';
}

$items = $value;
}
else {
$items = [$value];
}

foreach ($items as $item) {
$error = $this->scalarOptionError(is_scalar($item) ? (string) $item : '');
if ($error !== NULL) {
return $error;
}
}

return NULL;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Non-scalar multi-select items are silently coerced to an empty-string error instead of a clear message.

is_scalar($item) ? (string) $item : '' turns a malformed item (nested array/object from a --prompts JSON payload) into '', producing a confusing value "" is not one of: ... error rather than clearly naming the malformed item. This undercuts the PR goal of rejecting bad headless input "with clear errors."

🐛 Proposed fix
     foreach ($items as $item) {
-      $error = $this->scalarOptionError(is_scalar($item) ? (string) $item : '');
+      if (!is_scalar($item)) {
+        return 'value must be a list of scalars';
+      }
+
+      $error = $this->scalarOptionError((string) $item);
       if ($error !== NULL) {
         return $error;
       }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public function optionError(mixed $value): ?string {
if (!$this->type->constrainsToOptions() || $this->options === []) {
return NULL;
}
if ($this->type->isMulti()) {
if (!is_array($value)) {
return 'value must be a list';
}
$items = $value;
}
else {
$items = [$value];
}
foreach ($items as $item) {
$error = $this->scalarOptionError(is_scalar($item) ? (string) $item : '');
if ($error !== NULL) {
return $error;
}
}
return NULL;
}
public function optionError(mixed $value): ?string {
if (!$this->type->constrainsToOptions() || $this->options === []) {
return NULL;
}
if ($this->type->isMulti()) {
if (!is_array($value)) {
return 'value must be a list';
}
$items = $value;
}
else {
$items = [$value];
}
foreach ($items as $item) {
if (!is_scalar($item)) {
return 'value must be a list of scalars';
}
$error = $this->scalarOptionError((string) $item);
if ($error !== NULL) {
return $error;
}
}
return NULL;
}
🧰 Tools
🪛 PHPMD (2.15.0)

[error] 157-159: The method optionError uses an else expression. Else clauses are basically not necessary and you can simplify the code by not using them. (undefined)

(ElseExpression)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Config/Field.php` around lines 145 - 169, Update Field::optionError to
explicitly reject non-scalar items in multi-select values with a clear
malformed-item error before calling scalarOptionError; do not coerce arrays or
objects to an empty string. Preserve the existing scalar validation and return
the first relevant error.

Comment on lines +93 to +112
foreach ($this->options as $index => $option) {
if ($option->kind === OptionKind::Heading) {
$lines[] = $this->renderHeadingRow($theme, $option);

continue;
}

if ($option->kind === OptionKind::Separator) {
$lines[] = $this->renderSeparatorRow($theme);

continue;
}

if ($option->disabled) {
$lines[] = $theme->radio(FALSE) . ' ' . $this->renderDisabledLabel($theme, $option);

continue;
}

$lines[] = $this->renderRadioRow($theme, $option->label, $index === $this->cursor);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate render-dispatch loop across widgets.

This heading/separator/disabled/leaf-row loop is duplicated near-verbatim in SearchWidget::view() (same structure, filtered source) and, with a different leaf renderer, in MultiSelectWidget::view(). Any future fix (e.g. a new OptionKind, a rendering tweak) needs to be applied in three places consistently.

Consider hoisting the heading/separator/disabled dispatch into ChoiceListTrait as a shared helper that takes a closure for the "normal" leaf row:

♻️ Suggested extraction into `ChoiceListTrait`
protected function renderRows(ThemeInterface $theme, array $rows, \Closure $renderRow): array {
    $lines = [];

    foreach ($rows as $index => $option) {
      if ($option->kind === OptionKind::Heading) {
        $lines[] = $this->renderHeadingRow($theme, $option);
        continue;
      }

      if ($option->kind === OptionKind::Separator) {
        $lines[] = $this->renderSeparatorRow($theme);
        continue;
      }

      if ($option->disabled) {
        $lines[] = $renderRow($option, FALSE, TRUE);
        continue;
      }

      $lines[] = $renderRow($option, $index === $this->cursor, FALSE);
    }

    return $lines;
  }
-  public function view(ThemeInterface $theme): string {
-    $lines = [];
-
-    foreach ($this->options as $index => $option) {
-      if ($option->kind === OptionKind::Heading) {
-        $lines[] = $this->renderHeadingRow($theme, $option);
-
-        continue;
-      }
-
-      if ($option->kind === OptionKind::Separator) {
-        $lines[] = $this->renderSeparatorRow($theme);
-
-        continue;
-      }
-
-      if ($option->disabled) {
-        $lines[] = $theme->radio(FALSE) . ' ' . $this->renderDisabledLabel($theme, $option);
-
-        continue;
-      }
-
-      $lines[] = $this->renderRadioRow($theme, $option->label, $index === $this->cursor);
-    }
-
-    return implode("\n", $lines);
-  }
+  public function view(ThemeInterface $theme): string {
+    return implode("\n", $this->renderRows($theme, $this->options, fn (Option $option, bool $current, bool $disabled): string => $disabled
+      ? $theme->radio(FALSE) . ' ' . $this->renderDisabledLabel($theme, $option)
+      : $this->renderRadioRow($theme, $option->label, $current)));
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Widget/SelectWidget.php` around lines 93 - 112, Extract the shared
heading/separator/disabled dispatch from SelectWidget::view(),
SearchWidget::view(), and MultiSelectWidget::view() into a protected
renderRows() helper in ChoiceListTrait. Have it accept the theme, rows, and a
closure for normal leaf rendering, preserving cursor and disabled arguments,
then update all three views to use the helper while retaining their
widget-specific leaf renderers.

@AlexSkrypnyk AlexSkrypnyk force-pushed the feature/15-option-groups branch from ff56d7c to d7283ec Compare July 10, 2026 12:29
@github-actions

This comment has been minimized.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/Builder/FieldBuilder.php`:
- Around line 366-415: Update Toggle default resolution in resolveDefault() to
skip headings, separators, and disabled options by selecting the first option
for which selectable() is true. Return that option’s value, and retain the
existing type default when no selectable option exists, keeping
WidgetFactory::labels() and Toggle defaults aligned.

In `@src/Config/Option.php`:
- Around line 29-43: In Option::__construct, stop promoting $disabledReason
directly; declare an explicit camelCase disabledReason property on the class,
change the constructor parameter to snake_case $disabled_reason, and assign it
to $this->disabledReason within the constructor while preserving the existing
default and behavior.

In `@src/Widget/MultiSelectWidget.php`:
- Around line 148-174: visible() filtering is duplicated between
MultiSelectWidget and SearchWidget. Add a shared protected filterVisible(string
$filter): array method to ChoiceListTrait containing the existing empty-filter,
case-insensitive label matching, and Option-kind logic, then update both
widgets’ visible() methods to delegate to it and remove the duplicated
implementation.

In `@src/Widget/WidgetFactory.php`:
- Line 33: Rename the constructor argument in WidgetFactory::__construct() to
snake_case, such as $external_editor_available, while retaining the camelCase
property name $externalEditorAvailable by removing promotion and assigning the
argument to the property within the constructor.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 16236db3-6e6a-4c79-9ab4-52802e779f0b

📥 Commits

Reviewing files that changed from the base of the PR and between ff56d7c and d7283ec.

⛔ Files ignored due to path filters (8)
  • docs/assets/widget-multiselect-groups-ascii-no-ansi.svg is excluded by !**/*.svg
  • docs/assets/widget-multiselect-groups-ascii.svg is excluded by !**/*.svg
  • docs/assets/widget-multiselect-groups-no-ansi.svg is excluded by !**/*.svg
  • docs/assets/widget-multiselect-groups.svg is excluded by !**/*.svg
  • docs/assets/widget-select-groups-ascii-no-ansi.svg is excluded by !**/*.svg
  • docs/assets/widget-select-groups-ascii.svg is excluded by !**/*.svg
  • docs/assets/widget-select-groups-no-ansi.svg is excluded by !**/*.svg
  • docs/assets/widget-select-groups.svg is excluded by !**/*.svg
📒 Files selected for processing (32)
  • README.md
  • docs/util/update-assets.php
  • playground/3-widgets/widget-multiselect-groups.php
  • playground/3-widgets/widget-select-groups.php
  • playground/README.md
  • src/Builder/FieldBuilder.php
  • src/Config/Field.php
  • src/Config/FieldType.php
  • src/Config/Option.php
  • src/Config/OptionKind.php
  • src/Engine/Engine.php
  • src/Schema/SchemaGenerator.php
  • src/Schema/SchemaValidator.php
  • src/Theme/DefaultTheme.php
  • src/Theme/ThemeInterface.php
  • src/Widget/ChoiceListTrait.php
  • src/Widget/MultiSelectWidget.php
  • src/Widget/SearchWidget.php
  • src/Widget/SelectWidget.php
  • src/Widget/WidgetFactory.php
  • tests/phpunit/Traits/MixedOptionsTrait.php
  • tests/phpunit/Unit/Builder/FormTest.php
  • tests/phpunit/Unit/Config/OptionTest.php
  • tests/phpunit/Unit/Engine/EngineTest.php
  • tests/phpunit/Unit/Schema/SchemaGeneratorTest.php
  • tests/phpunit/Unit/Schema/SchemaValidatorTest.php
  • tests/phpunit/Unit/Theme/ThemeTest.php
  • tests/phpunit/Unit/Widget/MultiSearchWidgetTest.php
  • tests/phpunit/Unit/Widget/MultiSelectWidgetTest.php
  • tests/phpunit/Unit/Widget/SearchWidgetTest.php
  • tests/phpunit/Unit/Widget/SelectWidgetTest.php
  • tests/phpunit/Unit/Widget/WidgetFactoryTest.php

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/Builder/FieldBuilder.php`:
- Around line 366-415: Update Toggle default resolution in resolveDefault() to
skip headings, separators, and disabled options by selecting the first option
for which selectable() is true. Return that option’s value, and retain the
existing type default when no selectable option exists, keeping
WidgetFactory::labels() and Toggle defaults aligned.

In `@src/Config/Option.php`:
- Around line 29-43: In Option::__construct, stop promoting $disabledReason
directly; declare an explicit camelCase disabledReason property on the class,
change the constructor parameter to snake_case $disabled_reason, and assign it
to $this->disabledReason within the constructor while preserving the existing
default and behavior.

In `@src/Widget/MultiSelectWidget.php`:
- Around line 148-174: visible() filtering is duplicated between
MultiSelectWidget and SearchWidget. Add a shared protected filterVisible(string
$filter): array method to ChoiceListTrait containing the existing empty-filter,
case-insensitive label matching, and Option-kind logic, then update both
widgets’ visible() methods to delegate to it and remove the duplicated
implementation.

In `@src/Widget/WidgetFactory.php`:
- Line 33: Rename the constructor argument in WidgetFactory::__construct() to
snake_case, such as $external_editor_available, while retaining the camelCase
property name $externalEditorAvailable by removing promotion and assigning the
argument to the property within the constructor.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 16236db3-6e6a-4c79-9ab4-52802e779f0b

📥 Commits

Reviewing files that changed from the base of the PR and between ff56d7c and d7283ec.

⛔ Files ignored due to path filters (8)
  • docs/assets/widget-multiselect-groups-ascii-no-ansi.svg is excluded by !**/*.svg
  • docs/assets/widget-multiselect-groups-ascii.svg is excluded by !**/*.svg
  • docs/assets/widget-multiselect-groups-no-ansi.svg is excluded by !**/*.svg
  • docs/assets/widget-multiselect-groups.svg is excluded by !**/*.svg
  • docs/assets/widget-select-groups-ascii-no-ansi.svg is excluded by !**/*.svg
  • docs/assets/widget-select-groups-ascii.svg is excluded by !**/*.svg
  • docs/assets/widget-select-groups-no-ansi.svg is excluded by !**/*.svg
  • docs/assets/widget-select-groups.svg is excluded by !**/*.svg
📒 Files selected for processing (32)
  • README.md
  • docs/util/update-assets.php
  • playground/3-widgets/widget-multiselect-groups.php
  • playground/3-widgets/widget-select-groups.php
  • playground/README.md
  • src/Builder/FieldBuilder.php
  • src/Config/Field.php
  • src/Config/FieldType.php
  • src/Config/Option.php
  • src/Config/OptionKind.php
  • src/Engine/Engine.php
  • src/Schema/SchemaGenerator.php
  • src/Schema/SchemaValidator.php
  • src/Theme/DefaultTheme.php
  • src/Theme/ThemeInterface.php
  • src/Widget/ChoiceListTrait.php
  • src/Widget/MultiSelectWidget.php
  • src/Widget/SearchWidget.php
  • src/Widget/SelectWidget.php
  • src/Widget/WidgetFactory.php
  • tests/phpunit/Traits/MixedOptionsTrait.php
  • tests/phpunit/Unit/Builder/FormTest.php
  • tests/phpunit/Unit/Config/OptionTest.php
  • tests/phpunit/Unit/Engine/EngineTest.php
  • tests/phpunit/Unit/Schema/SchemaGeneratorTest.php
  • tests/phpunit/Unit/Schema/SchemaValidatorTest.php
  • tests/phpunit/Unit/Theme/ThemeTest.php
  • tests/phpunit/Unit/Widget/MultiSearchWidgetTest.php
  • tests/phpunit/Unit/Widget/MultiSelectWidgetTest.php
  • tests/phpunit/Unit/Widget/SearchWidgetTest.php
  • tests/phpunit/Unit/Widget/SelectWidgetTest.php
  • tests/phpunit/Unit/Widget/WidgetFactoryTest.php
🛑 Comments failed to post (4)
src/Builder/FieldBuilder.php (1)

366-415: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Choose the first selectable row for Toggle defaults.

These new APIs allow a heading, separator, or disabled option to precede the first selectable option, but resolveDefault() still returns reset($this->options)->value at Lines 480–482. A Toggle can therefore start with an empty or disabled default even though WidgetFactory::labels() excludes that row.

Update Toggle default resolution to return the first selectable() option, falling back to the type default when none exists.

Proposed fix
     if ($this->fieldType === FieldType::Toggle && $this->options !== []) {
-      return reset($this->options)->value;
+      foreach ($this->options as $option) {
+        if ($option->selectable()) {
+          return $option->value;
+        }
+      }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    if ($this->fieldType === FieldType::Toggle && $this->options !== []) {
      foreach ($this->options as $option) {
        if ($option->selectable()) {
          return $option->value;
        }
      }
    }
🧰 Tools
🪛 PHPMD (2.15.0)

[error] 374-374: The method option has a boolean flag argument $disabled, which is a certain sign of a Single Responsibility Principle violation. (undefined)

(BooleanArgumentFlag)


[error] 374-391: The parameter $disabled_reason is not named in camelCase. (undefined)

(CamelCaseParameterName)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Builder/FieldBuilder.php` around lines 366 - 415, Update Toggle default
resolution in resolveDefault() to skip headings, separators, and disabled
options by selecting the first option for which selectable() is true. Return
that option’s value, and retain the existing type default when no selectable
option exists, keeping WidgetFactory::labels() and Toggle defaults aligned.
src/Config/Option.php (1)

29-43: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Separate the promoted property from its constructor argument.

$disabledReason is both a class property and a constructor argument. The repository rules require camelCase properties but snake_case method arguments; declare the property explicitly and accept $disabled_reason, assigning it in the constructor.

As per coding guidelines, method arguments use snake_case while class properties use camelCase.

Proposed fix
 final readonly class Option {
+  public string $disabledReason;
+
   public function __construct(
     public string $value,
     public string $label,
     public string $description = '',
     public OptionKind $kind = OptionKind::Option,
     public bool $disabled = FALSE,
-    public string $disabledReason = '',
+    string $disabled_reason = '',
   ) {
+    $this->disabledReason = $disabled_reason;
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

  public string $disabledReason;

  /**
   * `@param` \DrevOps\Tui\Config\OptionKind $kind
   *   The row kind.
   * `@param` bool $disabled
   *   Whether a selectable Option row is shown but cannot be selected.
   * `@param` string $disabled_reason
   *   The reason shown beside a disabled option.
   */
  public function __construct(
    public string $value,
    public string $label,
    public string $description = '',
    public OptionKind $kind = OptionKind::Option,
    public bool $disabled = FALSE,
    string $disabled_reason = '',
  ) {
    $this->disabledReason = $disabled_reason;
  }
🧰 Tools
🪛 PHPMD (2.15.0)

[error] 41-41: The method __construct has a boolean flag argument $disabled, which is a certain sign of a Single Responsibility Principle violation. (undefined)

(BooleanArgumentFlag)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Config/Option.php` around lines 29 - 43, In Option::__construct, stop
promoting $disabledReason directly; declare an explicit camelCase disabledReason
property on the class, change the constructor parameter to snake_case
$disabled_reason, and assign it to $this->disabledReason within the constructor
while preserving the existing default and behavior.

Source: Coding guidelines

src/Widget/MultiSelectWidget.php (1)

148-174: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

visible() is duplicated verbatim in SearchWidget.

The filter-based visible() implementation here is identical to SearchWidget::visible() (same kind check, same case-insensitive str_contains match). Consider hoisting it into ChoiceListTrait as a shared filterVisible(string $filter): array (or similar) operating on $this->options, so future filtering tweaks (e.g. matching descriptions, fuzzy match) apply once instead of drifting across widgets.

♻️ Suggested extraction into `ChoiceListTrait`
protected function filterVisible(string $filter): array {
  if ($filter === '') {
    return $this->options;
  }

  $needle = strtolower($filter);

  return array_values(array_filter($this->options, fn(Option $option): bool => $option->kind === OptionKind::Option && str_contains(strtolower($option->label), $needle)));
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Widget/MultiSelectWidget.php` around lines 148 - 174, visible() filtering
is duplicated between MultiSelectWidget and SearchWidget. Add a shared protected
filterVisible(string $filter): array method to ChoiceListTrait containing the
existing empty-filter, case-insensitive label matching, and Option-kind logic,
then update both widgets’ visible() methods to delegate to it and remove the
duplicated implementation.
src/Widget/WidgetFactory.php (1)

33-33: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

$externalEditorAvailable argument should be snake_case.

As per coding guidelines, **/*.php: "Use snake_case for local variables and method arguments." This promoted constructor parameter is camelCase. Since the same identifier also serves as the class property (which must be camelCase), consider un-promoting it so the argument and property can each follow their respective convention.

🔧 Suggested fix
-  public function __construct(?KeyMap $keymap = NULL, protected bool $externalEditorAvailable = FALSE) {
+  public function __construct(?KeyMap $keymap = NULL, bool $external_editor_available = FALSE) {
     $this->keymap = $keymap ?? KeyMapManager::create();
+    $this->externalEditorAvailable = $external_editor_available;
   }
🧰 Tools
🪛 PHPMD (2.15.0)

[error] 33-33: The method __construct has a boolean flag argument $externalEditorAvailable, which is a certain sign of a Single Responsibility Principle violation. (undefined)

(BooleanArgumentFlag)


[warning] 33-33: Avoid excessively long variable names like $externalEditorAvailable. Keep variable name length under 20. (undefined)

(LongVariable)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Widget/WidgetFactory.php` at line 33, Rename the constructor argument in
WidgetFactory::__construct() to snake_case, such as $external_editor_available,
while retaining the camelCase property name $externalEditorAvailable by removing
promotion and assigning the argument to the property within the constructor.

Source: Coding guidelines

@AlexSkrypnyk AlexSkrypnyk force-pushed the feature/15-option-groups branch from d7283ec to 179d09b Compare July 10, 2026 13:30
@github-actions

Copy link
Copy Markdown
Code Coverage Report:
  2026-07-10 13:31:43

 Summary:
  Classes: 92.86% (65/70)
  Methods: 98.17% (483/492)
  Lines:   99.52% (2072/2082)

DrevOps\Tui\Answers\Answer
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  1/  1)
DrevOps\Tui\Answers\Answers
  Methods: 100.00% ( 9/ 9)   Lines: 100.00% ( 20/ 20)
DrevOps\Tui\Answers\SummaryFormatter
  Methods: 100.00% ( 3/ 3)   Lines: 100.00% ( 20/ 20)
DrevOps\Tui\Builder\FieldBuilder
  Methods: 100.00% (29/29)   Lines: 100.00% ( 96/ 96)
DrevOps\Tui\Builder\Form
  Methods:  93.33% (14/15)   Lines:  98.44% ( 63/ 64)
DrevOps\Tui\Builder\PanelBuilder
  Methods: 100.00% (19/19)   Lines: 100.00% ( 31/ 31)
DrevOps\Tui\Condition\CompositeCondition
  Methods: 100.00% ( 4/ 4)   Lines: 100.00% ( 19/ 19)
DrevOps\Tui\Condition\Condition
  Methods: 100.00% (10/10)   Lines: 100.00% ( 38/ 38)
DrevOps\Tui\Config\Config
  Methods: 100.00% ( 5/ 5)   Lines: 100.00% ( 17/ 17)
DrevOps\Tui\Config\Field
  Methods: 100.00% ( 5/ 5)   Lines: 100.00% ( 30/ 30)
DrevOps\Tui\Config\FieldType
  Methods: 100.00% ( 2/ 2)   Lines: 100.00% (  2/  2)
DrevOps\Tui\Config\NumberBounds
  Methods: 100.00% ( 6/ 6)   Lines: 100.00% ( 20/ 20)
DrevOps\Tui\Config\Option
  Methods: 100.00% ( 3/ 3)   Lines: 100.00% (  9/  9)
DrevOps\Tui\Config\Panel
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  1/  1)
DrevOps\Tui\Derive\Derive
  Methods: 100.00% ( 4/ 4)   Lines: 100.00% ( 14/ 14)
DrevOps\Tui\Derive\Deriver
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% ( 13/ 13)
DrevOps\Tui\Derive\Transform
  Methods: 100.00% ( 3/ 3)   Lines: 100.00% (  7/  7)
DrevOps\Tui\Discovery\AbstractDiscover
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  1/  1)
DrevOps\Tui\Discovery\Dotenv
  Methods: 100.00% ( 3/ 3)   Lines: 100.00% ( 19/ 19)
DrevOps\Tui\Discovery\JsonValue
  Methods: 100.00% ( 4/ 4)   Lines: 100.00% ( 16/ 16)
DrevOps\Tui\Discovery\PathExists
  Methods: 100.00% ( 3/ 3)   Lines: 100.00% (  3/  3)
DrevOps\Tui\Discovery\Scan
  Methods: 100.00% ( 3/ 3)   Lines: 100.00% ( 20/ 20)
DrevOps\Tui\Engine\Engine
  Methods: 100.00% (12/12)   Lines: 100.00% ( 95/ 95)
DrevOps\Tui\Handler\HandlerRegistry
  Methods:  85.71% ( 6/ 7)   Lines:  95.45% ( 21/ 22)
DrevOps\Tui\Input\ArrayKeyStream
  Methods: 100.00% ( 3/ 3)   Lines: 100.00% ( 12/ 12)
DrevOps\Tui\Input\Binding
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  1/  1)
DrevOps\Tui\Input\DefaultKeyMap
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% ( 33/ 33)
DrevOps\Tui\Input\Key
  Methods: 100.00% ( 7/ 7)   Lines: 100.00% (  7/  7)
DrevOps\Tui\Input\KeyMap
  Methods: 100.00% (12/12)   Lines: 100.00% ( 65/ 65)
DrevOps\Tui\Input\KeyMapManager
  Methods: 100.00% ( 2/ 2)   Lines: 100.00% (  8/  8)
DrevOps\Tui\Input\KeyParser
  Methods: 100.00% ( 6/ 6)   Lines: 100.00% ( 68/ 68)
DrevOps\Tui\Input\Scope
  Methods: 100.00% ( 7/ 7)   Lines: 100.00% ( 11/ 11)
DrevOps\Tui\Input\ScopedKeyMap
  Methods: 100.00% ( 4/ 4)   Lines: 100.00% (  4/  4)
DrevOps\Tui\Input\VimKeyMap
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  8/  8)
DrevOps\Tui\Render\Ansi
  Methods: 100.00% ( 4/ 4)   Lines: 100.00% (  5/  5)
DrevOps\Tui\Render\Box
  Methods: 100.00% ( 3/ 3)   Lines: 100.00% ( 15/ 15)
DrevOps\Tui\Render\ExternalEditor
  Methods: 100.00% ( 6/ 6)   Lines: 100.00% ( 27/ 27)
DrevOps\Tui\Render\Navigator
  Methods: 100.00% ( 6/ 6)   Lines: 100.00% ( 15/ 15)
DrevOps\Tui\Render\PanelController
  Methods: 100.00% (16/16)   Lines: 100.00% ( 95/ 95)
DrevOps\Tui\Render\Scroller
  Methods: 100.00% ( 4/ 4)   Lines: 100.00% ( 12/ 12)
DrevOps\Tui\Render\Terminal
  Methods: 100.00% (12/12)   Lines: 100.00% ( 35/ 35)
DrevOps\Tui\Render\TerminalControl
  Methods: 100.00% ( 9/ 9)   Lines: 100.00% (  9/  9)
DrevOps\Tui\Render\Viewport
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  1/  1)
DrevOps\Tui\Resolver\InputResolver
  Methods: 100.00% ( 6/ 6)   Lines: 100.00% ( 27/ 27)
DrevOps\Tui\Schema\AgentHelp
  Methods: 100.00% ( 3/ 3)   Lines: 100.00% ( 25/ 25)
DrevOps\Tui\Schema\SchemaGenerator
  Methods: 100.00% ( 3/ 3)   Lines: 100.00% ( 30/ 30)
DrevOps\Tui\Schema\SchemaValidator
  Methods: 100.00% ( 8/ 8)   Lines: 100.00% ( 37/ 37)
DrevOps\Tui\Theme\DefaultTheme
  Methods:  92.42% (61/66)   Lines:  97.26% (213/219)
DrevOps\Tui\Theme\ThemeManager
  Methods: 100.00% ( 2/ 2)   Lines: 100.00% (  8/  8)
DrevOps\Tui\Tui
  Methods: 100.00% (12/12)   Lines: 100.00% ( 26/ 26)
DrevOps\Tui\Widget\AbstractWidget
  Methods: 100.00% (13/13)   Lines: 100.00% ( 24/ 24)
DrevOps\Tui\Widget\ChoiceListTrait
  Methods: 100.00% ( 7/ 7)   Lines: 100.00% ( 21/ 21)
DrevOps\Tui\Widget\ConfirmWidget
  Methods: 100.00% ( 5/ 5)   Lines: 100.00% ( 22/ 22)
DrevOps\Tui\Widget\FilePickerWidget
  Methods: 100.00% (30/30)   Lines: 100.00% (187/187)
DrevOps\Tui\Widget\MultiSearchWidget
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  1/  1)
DrevOps\Tui\Widget\MultiSelectWidget
  Methods: 100.00% (11/11)   Lines: 100.00% ( 86/ 86)
DrevOps\Tui\Widget\NumberWidget
  Methods: 100.00% ( 9/ 9)   Lines: 100.00% ( 37/ 37)
DrevOps\Tui\Widget\PasswordDisplay
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  5/  5)
DrevOps\Tui\Widget\PasswordWidget
  Methods: 100.00% ( 8/ 8)   Lines: 100.00% ( 44/ 44)
DrevOps\Tui\Widget\PauseWidget
  Methods: 100.00% ( 4/ 4)   Lines: 100.00% ( 10/ 10)
DrevOps\Tui\Widget\SearchWidget
  Methods: 100.00% ( 6/ 6)   Lines: 100.00% ( 46/ 46)
DrevOps\Tui\Widget\SelectWidget
  Methods: 100.00% ( 5/ 5)   Lines: 100.00% ( 29/ 29)
DrevOps\Tui\Widget\SuggestWidget
  Methods: 100.00% ( 5/ 5)   Lines: 100.00% ( 38/ 38)
DrevOps\Tui\Widget\TextWidget
  Methods: 100.00% ( 7/ 7)   Lines: 100.00% ( 31/ 31)
DrevOps\Tui\Widget\TextareaWidget
  Methods: 100.00% ( 8/ 8)   Lines: 100.00% ( 52/ 52)
DrevOps\Tui\Widget\ToggleWidget
  Methods: 100.00% ( 7/ 7)   Lines: 100.00% ( 31/ 31)
DrevOps\Tui\Widget\WidgetFactory
  Methods: 100.00% ( 4/ 4)   Lines: 100.00% ( 30/ 30)
DrevOps\Tui\Widget\WidgetRunner
  Methods: 100.00% ( 1/ 1)   Lines: 100.00% (  5/  5)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/util/update-assets.php (1)

504-519: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Three near-identical job-registration loops - consider consolidating.

The widgetInteractions() loop, the picker loop, and the new group-demos loop all repeat the same "for each flag variant, build a widget-<name><suffix> job with command/interact/rows/cols/at_needle" pattern. Extracting a shared helper (e.g. registerWidgetJobs(array &$jobs, string $name, string $interact, string $gate, int $rows, int $cols, array $flag_variants, string $project_dir)) would reduce duplication and prevent future drift (e.g. the inconsistent cols values of 44 vs 60 across these blocks).

Also applies to: 546-559, 569-581

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/util/update-assets.php` around lines 504 - 519, Consolidate the
duplicated flag-variant job registration logic from the widgetInteractions loop
and the picker and group-demos loops into a shared helper, such as
registerWidgetJobs. Have it accept the jobs reference, name, interaction, gate,
row and column counts, flag variants, and project directory, then build each job
consistently; replace all three loops with calls to this helper while preserving
their intended values.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/Widget/MultiSelectWidget.php`:
- Around line 148-156: Move the duplicated filtering logic from
MultiSelectWidget::visible() and SearchWidget::visible() into a shared protected
filterOptions(string $filter): array method on ChoiceListTrait, preserving the
current empty-filter and case-insensitive label-matching behavior; update both
visible() methods to delegate to this helper.

In `@src/Widget/SearchWidget.php`:
- Around line 111-119: Extract the shared filtering logic from
SearchWidget::visible() and MultiSelectWidget::visible() into a
visibleRows(array $rows, string $filter): array helper in ChoiceListTrait.
Update both visible() methods to delegate to this helper while preserving the
empty-filter behavior and Option-kind substring matching.

In `@tests/phpunit/Unit/Widget/SearchWidgetTest.php`:
- Around line 24-25: Remove the redundant #[CoversClass(ChoiceListTrait::class)]
attribute from SearchWidgetTest, leaving SearchWidget::class as the sole
coverage target; this matches PHPUnit 11.5.55 behavior and documentation, where
covered classes include their used traits.

In `@tests/phpunit/Unit/Widget/SelectWidgetTest.php`:
- Around line 30-31: Remove the redundant #[CoversClass(ChoiceListTrait::class)]
attribute from SelectWidgetTest.php, keeping #[CoversClass(SelectWidget::class)]
as the coverage declaration for the trait used by SelectWidget.

---

Outside diff comments:
In `@docs/util/update-assets.php`:
- Around line 504-519: Consolidate the duplicated flag-variant job registration
logic from the widgetInteractions loop and the picker and group-demos loops into
a shared helper, such as registerWidgetJobs. Have it accept the jobs reference,
name, interaction, gate, row and column counts, flag variants, and project
directory, then build each job consistently; replace all three loops with calls
to this helper while preserving their intended values.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 77b63b39-b0d0-4b44-a61c-c4f95ff00e55

📥 Commits

Reviewing files that changed from the base of the PR and between d7283ec and 179d09b.

⛔ Files ignored due to path filters (8)
  • docs/assets/widget-multiselect-groups-ascii-no-ansi.svg is excluded by !**/*.svg
  • docs/assets/widget-multiselect-groups-ascii.svg is excluded by !**/*.svg
  • docs/assets/widget-multiselect-groups-no-ansi.svg is excluded by !**/*.svg
  • docs/assets/widget-multiselect-groups.svg is excluded by !**/*.svg
  • docs/assets/widget-select-groups-ascii-no-ansi.svg is excluded by !**/*.svg
  • docs/assets/widget-select-groups-ascii.svg is excluded by !**/*.svg
  • docs/assets/widget-select-groups-no-ansi.svg is excluded by !**/*.svg
  • docs/assets/widget-select-groups.svg is excluded by !**/*.svg
📒 Files selected for processing (32)
  • README.md
  • docs/util/update-assets.php
  • playground/3-widgets/widget-multiselect-groups.php
  • playground/3-widgets/widget-select-groups.php
  • playground/README.md
  • src/Builder/FieldBuilder.php
  • src/Config/Field.php
  • src/Config/FieldType.php
  • src/Config/Option.php
  • src/Config/OptionKind.php
  • src/Engine/Engine.php
  • src/Schema/SchemaGenerator.php
  • src/Schema/SchemaValidator.php
  • src/Theme/DefaultTheme.php
  • src/Theme/ThemeInterface.php
  • src/Widget/ChoiceListTrait.php
  • src/Widget/MultiSelectWidget.php
  • src/Widget/SearchWidget.php
  • src/Widget/SelectWidget.php
  • src/Widget/WidgetFactory.php
  • tests/phpunit/Traits/MixedOptionsTrait.php
  • tests/phpunit/Unit/Builder/FormTest.php
  • tests/phpunit/Unit/Config/OptionTest.php
  • tests/phpunit/Unit/Engine/EngineTest.php
  • tests/phpunit/Unit/Schema/SchemaGeneratorTest.php
  • tests/phpunit/Unit/Schema/SchemaValidatorTest.php
  • tests/phpunit/Unit/Theme/ThemeTest.php
  • tests/phpunit/Unit/Widget/MultiSearchWidgetTest.php
  • tests/phpunit/Unit/Widget/MultiSelectWidgetTest.php
  • tests/phpunit/Unit/Widget/SearchWidgetTest.php
  • tests/phpunit/Unit/Widget/SelectWidgetTest.php
  • tests/phpunit/Unit/Widget/WidgetFactoryTest.php

Comment on lines 148 to +156
protected function visible(): array {
if ($this->filter === '') {
return $this->values;
return $this->options;
}

$needle = strtolower($this->filter);

return array_values(array_filter($this->values, fn(string $value): bool => str_contains(strtolower($this->labels[$value] ?? $value), $needle)));
return array_values(array_filter($this->options, fn(Option $option): bool => $option->kind === OptionKind::Option && str_contains(strtolower($option->label), $needle)));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate filter logic with SearchWidget::visible().

This visible() implementation is byte-for-byte identical to SearchWidget::visible() (per repo graph context). Since ChoiceListTrait already centralizes shared option-list behavior (initOptions, firstSelectable, stepCursor, render helpers), consider moving this filter into the trait so both widgets share one implementation instead of two copies that can silently drift.

♻️ Proposed refactor: hoist filtering into ChoiceListTrait
- protected function visible(): array {
-   if ($this->filter === '') {
-     return $this->options;
-   }
-
-   $needle = strtolower($this->filter);
-
-   return array_values(array_filter($this->options, fn(Option $option): bool => $option->kind === OptionKind::Option && str_contains(strtolower($option->label), $needle)));
- }
+ protected function visible(): array {
+   return $this->filterOptions($this->filter);
+ }

And in ChoiceListTrait:

protected function filterOptions(string $filter): array {
  if ($filter === '') {
    return $this->options;
  }

  $needle = strtolower($filter);

  return array_values(array_filter($this->options, fn(Option $option): bool => $option->kind === OptionKind::Option && str_contains(strtolower($option->label), $needle)));
}

Apply the same change to SearchWidget::visible().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Widget/MultiSelectWidget.php` around lines 148 - 156, Move the duplicated
filtering logic from MultiSelectWidget::visible() and SearchWidget::visible()
into a shared protected filterOptions(string $filter): array method on
ChoiceListTrait, preserving the current empty-filter and case-insensitive
label-matching behavior; update both visible() methods to delegate to this
helper.

Comment on lines 111 to +119
protected function visible(): array {
if ($this->filter === '') {
return $this->values;
return $this->options;
}

$needle = strtolower($this->filter);

return array_values(array_filter($this->values, fn(string $value): bool => str_contains(strtolower($this->labels[$value] ?? $value), $needle)));
return array_values(array_filter($this->options, fn(Option $option): bool => $option->kind === OptionKind::Option && str_contains(strtolower($option->label), $needle)));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate filtering logic — could move into ChoiceListTrait.

visible() here is functionally identical to MultiSelectWidget::visible() (same Option-kind + substring-match predicate). ChoiceListTrait already centralizes firstSelectable/stepCursor/render helpers shared by these widgets; extracting a visibleRows(array $rows, string $filter): array helper there would remove this duplication and keep future filtering tweaks (e.g. matching on value too) in one place.

♻️ Proposed extraction into ChoiceListTrait
+  /**
+   * Rows matching a case-insensitive label filter (Option rows only).
+   *
+   * `@param` list<\DrevOps\Tui\Config\Option> $rows
+   *   The declared rows.
+   * `@param` string $filter
+   *   The current filter text; empty returns every row unchanged.
+   *
+   * `@return` list<\DrevOps\Tui\Config\Option>
+   *   The visible rows.
+   */
+  protected function visibleRows(array $rows, string $filter): array {
+    if ($filter === '') {
+      return $rows;
+    }
+
+    $needle = strtolower($filter);
+
+    return array_values(array_filter($rows, fn(Option $option): bool => $option->kind === OptionKind::Option && str_contains(strtolower($option->label), $needle)));
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Widget/SearchWidget.php` around lines 111 - 119, Extract the shared
filtering logic from SearchWidget::visible() and MultiSelectWidget::visible()
into a visibleRows(array $rows, string $filter): array helper in
ChoiceListTrait. Update both visible() methods to delegate to this helper while
preserving the empty-filter behavior and Option-kind substring matching.

Comment on lines +24 to 25
#[CoversClass(ChoiceListTrait::class)]
#[Group('widget')]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Redundant #[CoversClass(ChoiceListTrait::class)].

PHPUnit's #[CoversClass] already attributes coverage for the traits used by the targeted class, so SearchWidget::class alone covers ChoiceListTrait. PHPUnit's own coverage docs explicitly advise against targeting traits this way.

🧹 Proposed fix
 #[CoversClass(SearchWidget::class)]
-#[CoversClass(ChoiceListTrait::class)]
 #[Group('widget')]
 final class SearchWidgetTest extends TestCase {

Please confirm against the PHPUnit version pinned in this repo (11.5.55 per the library context) that #[CoversClass] on a class already attributes coverage for its traits, as documented for PHPUnit 11.x/12.x.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#[CoversClass(ChoiceListTrait::class)]
#[Group('widget')]
#[CoversClass(SearchWidget::class)]
#[Group('widget')]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/phpunit/Unit/Widget/SearchWidgetTest.php` around lines 24 - 25, Remove
the redundant #[CoversClass(ChoiceListTrait::class)] attribute from
SearchWidgetTest, leaving SearchWidget::class as the sole coverage target; this
matches PHPUnit 11.5.55 behavior and documentation, where covered classes
include their used traits.

Comment on lines +30 to 31
#[CoversClass(ChoiceListTrait::class)]
#[Group('widget')]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial

Redundant #[CoversClass(ChoiceListTrait::class)].

Same as flagged in SearchWidgetTest.php: #[CoversClass(SelectWidget::class)] already attributes coverage for ChoiceListTrait, since SelectWidget uses it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/phpunit/Unit/Widget/SelectWidgetTest.php` around lines 30 - 31, Remove
the redundant #[CoversClass(ChoiceListTrait::class)] attribute from
SelectWidgetTest.php, keeping #[CoversClass(SelectWidget::class)] as the
coverage declaration for the trait used by SelectWidget.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A4 Board worker 4

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support option groups, separators and disabled options

1 participant