diff --git a/README.md b/README.md index 998c76c7..5008e43f 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ Every feature has a reference page and a runnable, self-contained example in [`p | ⚙️ Declared behavior | `->required()` rejects an empty value with a label-derived or declared message; dynamic defaults, validation and transforms as field closures, or as per-field handler classes resolved by naming convention | [field behavior](https://phptui.dev/field-behaviour) | [`06-field-behaviour-*`](playground) | | 🔍 Discovery | Update mode detects defaults from an existing directory: dotenv keys, JSON dot-paths, path checks, directory scans | [discovery](https://phptui.dev/field-behaviour#discovery) | [`07-discovery`](playground/07-discovery.php) | | ⏳ Progress | A `progress()` primitive wraps slow work: a spinner when the length is unknown, a determinate bar when known - theme-drawn, animating on a TTY, degrading to a plain line when piped or headless | [progress](https://phptui.dev/progress) | [`15-progress-*`](playground) | +| 🎯 Answer-driven options | An `->options()` callback that asks for the run context resolves a choice field's list from the answers collected so far, so one field narrows by another - re-resolved as they change, honored by the panel, headless collection, the schema and the validator alike, and a choice the narrowed list drops does not survive in the answers, while one supplied headlessly is reported rather than dropped | [options from the answers](https://phptui.dev/field-behaviour#options-from-the-answers) | [`19-dynamic-options`](playground/19-dynamic-options.php) | | 🌐 Remote-backed options | `->optionsFrom()` resolves a search or suggest field's candidates from the live query - a themed `Loading…` while it runs, a typing burst settling into one call, a per-query cache, and `->minQuery()` holding it back until the query is worth sending | [options from a query](https://phptui.dev/progress#options-from-a-query) | [`17-query-options`](playground/17-query-options.php) | | 🧾 Output | An `output()` primitive draws the chrome around a form: boxes and cards, tables, five status lines, definition lists, wrapped text, rules and a banner - theme-drawn, dropping their color when piped or redirected | [output](https://phptui.dev/output) | [`18-output-*`](playground) | | 📦 Self-describing answers | Answers carry provenance; `toSummary()` renders a badged, panel-grouped report and `toJson()` the machine result; `schema()`, `validate()` and `agentHelp()` describe the form itself | [self-describing answers](https://phptui.dev/headless-collection#self-describing-answers) | [`08-headless-*`](playground) | diff --git a/docs/architecture/dataflow-collect-dark.svg b/docs/architecture/dataflow-collect-dark.svg index 74ec2a6e..55917d13 100644 --- a/docs/architecture/dataflow-collect-dark.svg +++ b/docs/architecture/dataflow-collect-dark.svg @@ -1 +1 @@ -Data flow: headless collection (Engine::collect)InputResolverEngineDeriverConditionCallerInputResolverEngineField behaviourDiscovery specDeriverConditionAnswersCallerCallerInputResolverInputResolverEngineEngineField behaviour(closure or static)Field behaviour(closure or static)Discovery specDiscovery specDeriver+ DeriveDeriver+ DeriveConditionConditionAnswersAnswersInputResolverEngineDeriverConditionresolve(fields, prompts, env)input valuescollect(inputs, context)loop[each field, in order]alt[update mode and no input]discover(directory)discovered valueadopted only when it passes the field'semptiness, type, bounds and optionsinput > discovered > declared default > static defaultloop[each field with a supplied input]transform(value)normalized valueinputs normalize first, so derivation,activation and fix-ups see the final valuederive(rules, values, pinned)derived values (fixpoint)matches(answers)active / inactive per fieldapply fix-ups; repeat until stableloop[each active field with a supplied input]validate(value)error or nulla required field's emptiness, then typeand bounds; the first error throwsbuild(values, provenance)Answers \ No newline at end of file +Data flow: headless collection (Engine::collect)InputResolverEngineDeriverConditionCallerInputResolverEngineField behaviourDiscovery specDeriverConditionAnswersCallerCallerInputResolverInputResolverEngineEngineField behaviour(closure or static)Field behaviour(closure or static)Discovery specDiscovery specDeriver+ DeriveDeriver+ DeriveConditionConditionAnswersAnswersInputResolverEngineDeriverConditionresolve(fields, prompts, env)input valuescollect(inputs, context)loop[each field, in order]alt[update mode and no input]discover(directory)discovered valueadopted only when it passes the field'semptiness, type, bounds and optionsinput > discovered > declared default > static defaultloop[each field with a supplied input]transform(value)normalized valueinputs normalize first, so derivation,activation and fix-ups see the final valueloop[each field whose options follow the answers]options callback(context)value => label mapresolved before conditions evaluate; a value theset no longer holds is dropped unless it was suppliedderive(rules, values, pinned)derived values (fixpoint)matches(answers)active / inactive per fieldapply fix-ups; repeat until stableloop[each active field with a supplied input]validate(value)error or nulla required field's emptiness, then typeand bounds; the first error throwsbuild(values, provenance)Answers \ No newline at end of file diff --git a/docs/architecture/dataflow-collect.puml b/docs/architecture/dataflow-collect.puml index 452ee519..d90fda27 100644 --- a/docs/architecture/dataflow-collect.puml +++ b/docs/architecture/dataflow-collect.puml @@ -40,6 +40,12 @@ loop each field with a supplied input note right of Eng: inputs normalize first, so derivation,\nactivation and fix-ups see the final value end +loop each field whose options follow the answers + Eng -> B: options callback(context) + B --> Eng: value => label map + note right of Eng: resolved before conditions evaluate; a value the\nset no longer holds is dropped unless it was supplied +end + Eng -> Der: derive(rules, values, pinned) activate Der Der --> Eng: derived values (fixpoint) diff --git a/docs/architecture/dataflow-collect.svg b/docs/architecture/dataflow-collect.svg index d056f292..1c329d3a 100644 --- a/docs/architecture/dataflow-collect.svg +++ b/docs/architecture/dataflow-collect.svg @@ -1 +1 @@ -Data flow: headless collection (Engine::collect)InputResolverEngineDeriverConditionCallerInputResolverEngineField behaviourDiscovery specDeriverConditionAnswersCallerCallerInputResolverInputResolverEngineEngineField behaviour(closure or static)Field behaviour(closure or static)Discovery specDiscovery specDeriver+ DeriveDeriver+ DeriveConditionConditionAnswersAnswersInputResolverEngineDeriverConditionresolve(fields, prompts, env)input valuescollect(inputs, context)loop[each field, in order]alt[update mode and no input]discover(directory)discovered valueadopted only when it passes the field'semptiness, type, bounds and optionsinput > discovered > declared default > static defaultloop[each field with a supplied input]transform(value)normalized valueinputs normalize first, so derivation,activation and fix-ups see the final valuederive(rules, values, pinned)derived values (fixpoint)matches(answers)active / inactive per fieldapply fix-ups; repeat until stableloop[each active field with a supplied input]validate(value)error or nulla required field's emptiness, then typeand bounds; the first error throwsbuild(values, provenance)Answers \ No newline at end of file +Data flow: headless collection (Engine::collect)InputResolverEngineDeriverConditionCallerInputResolverEngineField behaviourDiscovery specDeriverConditionAnswersCallerCallerInputResolverInputResolverEngineEngineField behaviour(closure or static)Field behaviour(closure or static)Discovery specDiscovery specDeriver+ DeriveDeriver+ DeriveConditionConditionAnswersAnswersInputResolverEngineDeriverConditionresolve(fields, prompts, env)input valuescollect(inputs, context)loop[each field, in order]alt[update mode and no input]discover(directory)discovered valueadopted only when it passes the field'semptiness, type, bounds and optionsinput > discovered > declared default > static defaultloop[each field with a supplied input]transform(value)normalized valueinputs normalize first, so derivation,activation and fix-ups see the final valueloop[each field whose options follow the answers]options callback(context)value => label mapresolved before conditions evaluate; a value theset no longer holds is dropped unless it was suppliedderive(rules, values, pinned)derived values (fixpoint)matches(answers)active / inactive per fieldapply fix-ups; repeat until stableloop[each active field with a supplied input]validate(value)error or nulla required field's emptiness, then typeand bounds; the first error throwsbuild(values, provenance)Answers \ No newline at end of file diff --git a/docs/architecture/dataflow-tui-dark.svg b/docs/architecture/dataflow-tui-dark.svg index 273b475d..14db56e7 100644 --- a/docs/architecture/dataflow-tui-dark.svg +++ b/docs/architecture/dataflow-tui-dark.svg @@ -1 +1 @@ -Data flow: interactive panel TUI (PanelController::run)PanelControllerUserPanelControllerTerminalThemeNavigatorKeyParserKeyMapWidgetEngineUserUserPanelControllerPanelControllerTerminalTerminalThemeThemeNavigator+ ScrollerNavigator+ ScrollerKeyParserKeyParserKeyMap(scoped)KeyMap(scoped)Widget(via factory)Widget(via factory)EngineEnginePanelControllersetup() raw mode, alt screenbanner(logo, version)banner textrender(banner)loop[until done]alt[fullscreen and terminal below the minimum size]centered "terminal too small" notice(only quit is handled)[laid out]body(panel, answers, cursor)rows + cursor linecompute viewport(theme-owned chrome height)visible windowalt[current panel is a modal]renderModal(dialog, dimmed backdrop)composited frame[hub or field editor]frame(header, body, footer, viewport)frame text(fullscreen: stretched, block aligned)position frame in the screen(fullscreen: Overlay place by halign/valign)render(frame)key pressraw bytesparse(bytes)Key listalt[editing a field]handle(key)matches(key, action)?bound action (accept, move, toggle...)value, complete or cancelopt[edit accepted]settle(values, pinned)settled values + active map(derives recompute, conditionsshow/hide fields, fix-ups apply)[navigating]matches(key, action)?bound action (move, activate, back, quit)move cursor, drill panel, edit a field in place,or open a modal dialog over the dimmed parent;a modal's submit keeps edits, cancel restores the opening answersrestore()Answers \ No newline at end of file +Data flow: interactive panel TUI (PanelController::run)PanelControllerUserPanelControllerTerminalThemeNavigatorKeyParserKeyMapWidgetEngineUserUserPanelControllerPanelControllerTerminalTerminalThemeThemeNavigator+ ScrollerNavigator+ ScrollerKeyParserKeyParserKeyMap(scoped)KeyMap(scoped)Widget(via factory)Widget(via factory)EngineEnginePanelControllersetup() raw mode, alt screenbanner(logo, version)banner textrender(banner)loop[until done]alt[fullscreen and terminal below the minimum size]centered "terminal too small" notice(only quit is handled)[laid out]body(panel, answers, cursor)rows + cursor linecompute viewport(theme-owned chrome height)visible windowalt[current panel is a modal]renderModal(dialog, dimmed backdrop)composited frame[hub or field editor]frame(header, body, footer, viewport)frame text(fullscreen: stretched, block aligned)position frame in the screen(fullscreen: Overlay place by halign/valign)render(frame)key pressraw bytesparse(bytes)Key listalt[editing a field]handle(key)matches(key, action)?bound action (accept, move, toggle...)value, complete or cancelopt[edit accepted]settle(values, pinned, context)settled values + active map(options resolve from the answers,derives recompute, conditionsshow/hide fields, fix-ups apply)[navigating]matches(key, action)?bound action (move, activate, back, quit)move cursor, drill panel, edit a field in place,or open a modal dialog over the dimmed parent;a modal's submit keeps edits, cancel restores the opening answersrestore()Answers \ No newline at end of file diff --git a/docs/architecture/dataflow-tui.puml b/docs/architecture/dataflow-tui.puml index 279138d3..dc11b817 100644 --- a/docs/architecture/dataflow-tui.puml +++ b/docs/architecture/dataflow-tui.puml @@ -3,7 +3,8 @@ ' Traced from src/Render/PanelController.php (run -> frame/handle), src/Theme/ ' (frame, and renderModal composited via src/Render/Overlay), src/Input/ ' (KeyMap resolves a key press to a semantic action) and src/Engine/Engine.php -' (settle re-runs derives, conditions and fix-ups after an accepted edit). +' (settle re-resolves answer-driven options and re-runs derives, conditions and +' fix-ups after an accepted edit). ' Regenerate every SVG with: plantuml -tsvg docs/architecture/*.puml !theme plain skinparam backgroundColor white @@ -57,8 +58,8 @@ loop until done KM --> W: bound action (accept, move, toggle...) W --> PC: value, complete or cancel opt edit accepted - PC -> Eng: settle(values, pinned) - Eng --> PC: settled values + active map\n(derives recompute, conditions\nshow/hide fields, fix-ups apply) + PC -> Eng: settle(values, pinned, context) + Eng --> PC: settled values + active map\n(options resolve from the answers,\nderives recompute, conditions\nshow/hide fields, fix-ups apply) end else navigating PC -> KM: matches(key, action)? diff --git a/docs/architecture/dataflow-tui.svg b/docs/architecture/dataflow-tui.svg index b1715967..3d457a13 100644 --- a/docs/architecture/dataflow-tui.svg +++ b/docs/architecture/dataflow-tui.svg @@ -1 +1 @@ -Data flow: interactive panel TUI (PanelController::run)PanelControllerUserPanelControllerTerminalThemeNavigatorKeyParserKeyMapWidgetEngineUserUserPanelControllerPanelControllerTerminalTerminalThemeThemeNavigator+ ScrollerNavigator+ ScrollerKeyParserKeyParserKeyMap(scoped)KeyMap(scoped)Widget(via factory)Widget(via factory)EngineEnginePanelControllersetup() raw mode, alt screenbanner(logo, version)banner textrender(banner)loop[until done]alt[fullscreen and terminal below the minimum size]centered "terminal too small" notice(only quit is handled)[laid out]body(panel, answers, cursor)rows + cursor linecompute viewport(theme-owned chrome height)visible windowalt[current panel is a modal]renderModal(dialog, dimmed backdrop)composited frame[hub or field editor]frame(header, body, footer, viewport)frame text(fullscreen: stretched, block aligned)position frame in the screen(fullscreen: Overlay place by halign/valign)render(frame)key pressraw bytesparse(bytes)Key listalt[editing a field]handle(key)matches(key, action)?bound action (accept, move, toggle...)value, complete or cancelopt[edit accepted]settle(values, pinned)settled values + active map(derives recompute, conditionsshow/hide fields, fix-ups apply)[navigating]matches(key, action)?bound action (move, activate, back, quit)move cursor, drill panel, edit a field in place,or open a modal dialog over the dimmed parent;a modal's submit keeps edits, cancel restores the opening answersrestore()Answers \ No newline at end of file +Data flow: interactive panel TUI (PanelController::run)PanelControllerUserPanelControllerTerminalThemeNavigatorKeyParserKeyMapWidgetEngineUserUserPanelControllerPanelControllerTerminalTerminalThemeThemeNavigator+ ScrollerNavigator+ ScrollerKeyParserKeyParserKeyMap(scoped)KeyMap(scoped)Widget(via factory)Widget(via factory)EngineEnginePanelControllersetup() raw mode, alt screenbanner(logo, version)banner textrender(banner)loop[until done]alt[fullscreen and terminal below the minimum size]centered "terminal too small" notice(only quit is handled)[laid out]body(panel, answers, cursor)rows + cursor linecompute viewport(theme-owned chrome height)visible windowalt[current panel is a modal]renderModal(dialog, dimmed backdrop)composited frame[hub or field editor]frame(header, body, footer, viewport)frame text(fullscreen: stretched, block aligned)position frame in the screen(fullscreen: Overlay place by halign/valign)render(frame)key pressraw bytesparse(bytes)Key listalt[editing a field]handle(key)matches(key, action)?bound action (accept, move, toggle...)value, complete or cancelopt[edit accepted]settle(values, pinned, context)settled values + active map(options resolve from the answers,derives recompute, conditionsshow/hide fields, fix-ups apply)[navigating]matches(key, action)?bound action (move, activate, back, quit)move cursor, drill panel, edit a field in place,or open a modal dialog over the dimmed parent;a modal's submit keeps edits, cancel restores the opening answersrestore()Answers \ No newline at end of file diff --git a/docs/content/field-behaviour.mdx b/docs/content/field-behaviour.mdx index 17de5059..4935578a 100644 --- a/docs/content/field-behaviour.mdx +++ b/docs/content/field-behaviour.mdx @@ -1,7 +1,7 @@ --- title: Field behavior -description: 'Guide an answer with a description, a hint and a placeholder; mark fields required, and declare dynamic defaults, validation and transforms as closures on the field - or in handler classes - and detect defaults with discovery rules.' -keywords: ['description', 'hint', 'placeholder', 'required', 'validation', 'transform', 'dynamic default', 'discovery', 'handler'] +description: 'Guide an answer with a description, a hint and a placeholder; mark fields required, narrow one field options by another answer, and declare dynamic defaults, validation and transforms as closures on the field - or in handler classes - and detect defaults with discovery rules.' +keywords: ['description', 'hint', 'placeholder', 'required', 'validation', 'transform', 'dynamic default', 'dynamic options', 'discovery', 'handler'] --- import ThemedImage from '@theme/ThemedImage'; @@ -79,6 +79,45 @@ Emptying the name shows the label-derived message in the editor; leaving the bas

+## Options from the answers + +A choice field's list does not have to be fixed. Hand `->options()` a callback that asks for the run context, and one field's choices narrow by another's answer: + +```php +use DrevOps\Tui\Handler\Context; + +$catalog = [ + 'fruit' => ['apple' => 'Apple', 'banana' => 'Banana', 'cherry' => 'Cherry'], + 'vegetable' => ['carrot' => 'Carrot', 'potato' => 'Potato', 'tomato' => 'Tomato'], +]; + +$p->select('category', 'Category')->options(['fruit' => 'Fruit', 'vegetable' => 'Vegetable']); + +$p->select('item', 'Item')->options(function (Context $c) use ($catalog): array { + // An answer is whatever was supplied until it is validated, so read it + // defensively before it indexes anything. + $category = $c->answers['category'] ?? ''; + + return is_string($category) ? ($catalog[$category] ?? []) : []; +}); +``` + +**The callback's own signature says when it runs.** One that asks for the context follows the answers, as above; one that asks for nothing is the [loader](/progress#inside-the-form) it has always been - resolved once when the panel opens, showing a themed `Loading…` until it returns, and resolved up front when collection is headless and there is no panel to open. Either way it returns the same `value => label` map the fixed form takes, and the context carries the answers collected so far alongside the target directory, the update flag and the version. + +Options are for the types that have a list - `select`, `search`, `suggest`, `toggle` and `reorder`. Declaring them on any other type raises a `FormException` when the form is built, as does declaring a resolver beside a fixed list, a loader or a [query source](/progress#options-from-a-query), since the resolved set replaces them. + +A resolver runs as part of the form settling - the same pass that computes derived values, evaluates `when` conditions and applies fix-ups - so every surface sees one narrowed list: + +- **Interactively**, changing the category re-resolves the item list before the next frame, so the editor offers exactly what the new category holds. +- **Headlessly**, a supplied value is checked against the list the payload's own answers resolve to. A value outside it throws an `EngineException` naming the value and what was allowed. +- **[`Tui::validate()`](/ai-agents)** checks membership against the set the answers under validation resolve to, and the [schema](/ai-agents) resolves the list against whatever context you pass it, flagging the field as `options_dynamic` so tooling can tell an empty list from one that is not fixed. + +A choice the narrowed list no longer holds does not survive in the answers: it is dropped, a `reorder` ranking is completed back to a full permutation, and a `toggle` returns to its first state. Only a value supplied headlessly is left standing, so it is reported rather than disappearing without a word. A `suggest` field's options are hints rather than a closed set, so its value is never narrowed away. + +Keep the resolver cheap. It runs for the whole form on every settle, not once per panel, and it is called again whenever the answers change - though a settle that changes nothing costs nothing, since the list already answers those answers. For a list that is expensive to build, drop the context parameter and it loads [once, on panel entry](/progress#inside-the-form); for one that lives behind a search API, [`->optionsFrom()`](/progress#options-from-a-query) follows the query instead. + +Runnable in [`playground/19-dynamic-options.php`](https://github.com/drevops/tui/blob/main/playground/19-dynamic-options.php). + ## Discovery In update mode, `->discover()` rules detect defaults from an existing project directory: a `.env` key (`new Dotenv('SEASON')`), a JSON dot-path (`new JsonValue('basket.json', 'name')`), a path check (`new PathExists('harvest.csv')`), a directory scan (`new Scan('baskets', type: ScanType::Dir)`), or a custom `fn(Context $c): mixed` closure: diff --git a/docs/content/progress.mdx b/docs/content/progress.mdx index 6c242832..30ee2eae 100644 --- a/docs/content/progress.mdx +++ b/docs/content/progress.mdx @@ -111,9 +111,10 @@ php playground/15-progress-bar.php 2>&1 | cat `progress()` runs _around_ the form. Four counterparts show feedback _inside_ the interactive panel, drawn by the same theme. -- **Loading a field's options.** `->options()` takes a callback instead of a fixed list; it resolves when the field's panel opens, the field showing a themed `Loading…` until it returns. Headless collection resolves it up front. +- **Loading a field's options.** `->options()` takes a callback instead of a fixed list; one that asks for no arguments resolves when the field's panel opens, the field showing a themed `Loading…` until it returns. Headless collection resolves it up front. - **Preloading a panel.** `->preload(closure)` on a panel runs once, before the panel's fields first draw - prep the panel needs, fetched on entry rather than up front, so one fetch can feed several fields. - **[Options that follow the query](#options-from-a-query).** `->optionsFrom()` is called again on every query change rather than once, for candidates that live behind a search API. +- **[Options that follow the answers](/field-behaviour#options-from-the-answers).** The same `->options()` callback, but asking for the run context: it is called again whenever the answers change, so one field's choices narrow by another's answer. It resolves during the form settling rather than on entry, so it shows no indicator - keep it cheap. - **The [progress widget](/widgets/progress).** A panel row that runs its work when activated, filling a bar or ticking a spinner in the row itself. Unlike `progress()`, it lives among the fields and collects no value. ```php @@ -130,7 +131,7 @@ Runnable in [`playground/16-loading-data.php`](https://github.com/drevops/tui/bl ## Options from a query -A loader resolves one list, once. When the candidates are too many to hold - a catalog behind a search API, a database lookup, an index - a [search](/widgets/search) or [suggest](/widgets/suggest) field can source them from the query instead, with `->optionsFrom()`: +A loader resolves one list, once. When the candidates are too many to hold - a catalog behind a search API, a database lookup, an index - a [search](/widgets/search) or [suggest](/widgets/suggest) field can source them from the query instead, with `->optionsFrom()`. (For a list that follows the _answers_ rather than the query, see [options from the answers](/field-behaviour#options-from-the-answers).) ```php $form->panel('order', 'New order', function (PanelBuilder $p) use ($pantry): void { diff --git a/docs/content/widgets/select.mdx b/docs/content/widgets/select.mdx index 31058167..9b1e72f2 100644 --- a/docs/content/widgets/select.mdx +++ b/docs/content/widgets/select.mdx @@ -30,13 +30,13 @@ Runnable scripts: [`playground/02-widgets-select.php`](https://github.com/drevop ## Options -| Name | Description | Required | Default | -| ------------ | -------------------------------------------------------------------------------- | -------- | ------------ | -| `options()` | The choices, as a `value => label` map (or added one at a time with `option()`). | Yes | - | -| `default()` | Which option starts highlighted, by value. | No | First option | -| `pageSize()` | Options shown before the list pages around the cursor. | No | `10` | +| Name | Description | Required | Default | +| ------------ | -------------------------------------------------------------------------------------------------------------------------- | -------- | ------------ | +| `options()` | The choices, as a `value => label` map (or added one at a time with `option()`). Also takes a callback returning that map. | Yes | - | +| `default()` | Which option starts highlighted, by value. | No | First option | +| `pageSize()` | Options shown before the list pages around the cursor. | No | `10` | -For headings, separators and disabled options, see [Option groups](/widgets/option-groups). +For headings, separators and disabled options, see [Option groups](/widgets/option-groups). To narrow the choices by an earlier answer, see [options from the answers](/field-behaviour#options-from-the-answers). ## Option descriptions diff --git a/playground/19-dynamic-options.php b/playground/19-dynamic-options.php new file mode 100644 index 00000000..7664ca84 --- /dev/null +++ b/playground/19-dynamic-options.php @@ -0,0 +1,62 @@ +options() that asks for the run context is called + * again whenever the answers change, so one field's choices can narrow by + * another's answer. It runs as part of the form settling, before anything is + * drawn or validated, so the narrowed list is what the panel offers, what a + * headless payload is checked against and what the schema advertises - and a + * choice the narrowed list no longer holds is dropped from the answers, unless + * it was supplied headlessly, which collection reports instead. + * + * Usage: + * php playground/19-dynamic-options.php + */ + +declare(strict_types=1); + +use DrevOps\Tui\Builder\Form; +use DrevOps\Tui\Builder\PanelBuilder; +use DrevOps\Tui\Handler\Context; +use DrevOps\Tui\InterruptException; +use DrevOps\Tui\Tui; + +require __DIR__ . '/../vendor/autoload.php'; + +$catalog = [ + 'fruit' => ['apple' => 'Apple', 'banana' => 'Banana', 'cherry' => 'Cherry'], + 'vegetable' => ['carrot' => 'Carrot', 'potato' => 'Potato', 'tomato' => 'Tomato'], +]; + +$form = Form::create('Quick start') + ->panel('order', 'New order', function (PanelBuilder $p) use ($catalog): void { + $p->text('name', 'Order name')->required(); + + $p->select('category', 'Category')->options(['fruit' => 'Fruit', 'vegetable' => 'Vegetable'])->default('fruit'); + + // An answer is whatever was supplied until it is validated, so the category + // is read defensively before it indexes anything. + $stock = static function (Context $context) use ($catalog): array { + $category = $context->answers['category'] ?? ''; + + return is_string($category) ? ($catalog[$category] ?? []) : []; + }; + + // Called again whenever the answers change: pick another category and the + // item list follows, dropping an item the new category does not stock. + $p->select('item', 'Item')->options($stock); + + // The same narrowing over several picks - the basket keeps only what the + // chosen category still offers. + $p->select('basket', 'Basket')->multiple()->options($stock); + }); + +try { + echo (new Tui($form))->run()->toJson() . PHP_EOL; +} +catch (InterruptException) { + exit(130); +} diff --git a/playground/README.md b/playground/README.md index 18ec1c20..2ce3dab1 100644 --- a/playground/README.md +++ b/playground/README.md @@ -33,6 +33,7 @@ Every interactive script also runs unattended: pipe stdin (or run it from CI) an | `16-loading-data` | Loading a panel's data on demand: a field's `->options()` and a panel's `->preload()` taking a callback, resolved the first time the panel opens with a themed `Loading…` on the field. | [`16-loading-data.php`](16-loading-data.php) | | `17-query-options` | Options that follow the query: `->optionsFrom()` called again on every query change with a themed `Loading…` while it runs, a per-query cache, and `->minQuery()` holding the call back until the query is long enough. | [`17-query-options.php`](17-query-options.php) | | `18-output-*` | The output primitives - a titled box and card, an aligned table, the five status lines, a definition list, wrapped prose, rules and a banner - theme-drawn chrome for around a form run, dropping their colour when piped or redirected. | [`18-output-box.php`](18-output-box.php), [`18-output-status.php`](18-output-status.php), [`18-output-definitions.php`](18-output-definitions.php), [`18-output-table.php`](18-output-table.php), [`18-output-text.php`](18-output-text.php) | +| `19-dynamic-options` | Options that follow the answers: an `->options()` callback taking the run context, called again whenever they change, narrowing one field's choices by another's answer and dropping a choice the narrowed list no longer holds - reporting rather than dropping one that was supplied headlessly. | [`19-dynamic-options.php`](19-dynamic-options.php) | ## Running the examples diff --git a/src/Builder/FieldBuilder.php b/src/Builder/FieldBuilder.php index 0be2e537..2094dc6e 100644 --- a/src/Builder/FieldBuilder.php +++ b/src/Builder/FieldBuilder.php @@ -91,6 +91,11 @@ final class FieldBuilder { */ protected ?\Closure $optionsSource = NULL; + /** + * A resolver for the options, or NULL when they do not follow the answers. + */ + protected ?\Closure $optionsResolver = NULL; + /** * The query length below which the query source is not called. */ @@ -1087,23 +1092,45 @@ public function heading(string $label): self { } /** - * Add several options from a value => label map, or a loader for them. + * Add several options from a value => label map, or a callback for them. + * + * A callback's own signature says when it runs. One that asks for the run + * context follows the collected answers: it is called again whenever they + * change, so one field's choices can narrow by another's answer - a basket + * that stops offering what the chosen category does not hold. It runs as + * part of the form settling, before anything is drawn or validated, so the + * narrowed set is the one every surface sees: the panel, headless + * collection, the schema and the validator. A value the narrowed set no + * longer offers is dropped from the answers - a ranking is completed back to + * a full permutation and a toggle falls back to its first state - unless it + * was supplied headlessly, which is reported instead. Keep such a callback + * cheap: it runs for the whole form, not once per panel. + * + * A callback that asks for nothing loads one list, once, lazily when the + * field's panel opens - showing a themed "Loading…" beside the field until + * it returns, and running after the panel's `->preload()` so it can read + * what preload prepared. * * @param array|\Closure $options - * The options keyed by value with a label, or an - * `fn(): array` that loads them on demand. A loader resolves - * lazily when the field's panel opens - showing a themed "Loading…" beside - * the field until it returns. A loader suits a field whose default is empty - * or explicit (select, search, suggest); a toggle or reorder derives its - * default from the options, so with a loader it should declare an explicit - * `->default()`. + * The options keyed by value with a label, or a callback returning that + * same map: `fn (Context $context): array` to follow the + * answers, or `fn (): array` to load them once. Either way + * a toggle or reorder derives its default from the options, so one whose + * options arrive later should declare an explicit `->default()`. * * @return $this * The builder. */ public function options(array|\Closure $options): self { if ($options instanceof \Closure) { - $this->optionsLoader = $options; + // Reading the signature here, rather than at every call, keeps the two + // lifecycles apart without a reflection call mid-session. + if ((new \ReflectionFunction($options))->getNumberOfParameters() > 0) { + $this->optionsResolver = $options; + } + else { + $this->optionsLoader = $options; + } return $this; } @@ -1255,6 +1282,7 @@ public function build(): Field { envAliases: $this->envAliases, ghost: $this->ghost, ratingCaptions: $this->captions, + optionsResolver: $this->optionsResolver, ); } diff --git a/src/Builder/Form.php b/src/Builder/Form.php index e2524a0d..6b01d4a2 100644 --- a/src/Builder/Form.php +++ b/src/Builder/Form.php @@ -263,6 +263,12 @@ protected function assertToggleOptions(FormDefinition $form): void { continue; } + // Rows that arrive later cannot be counted here, and the default they + // would be checked against is the one they will settle on. + if (!$field->hasSettledOptions()) { + continue; + } + if (count($field->options) !== 2) { throw new FormException(sprintf('Toggle field "%s" must have exactly two options, %d given.', $field->id, count($field->options))); } @@ -297,6 +303,11 @@ protected function assertReorderOptions(FormDefinition $form): void { continue; } + // Rows that arrive later are not there to be counted or vetted here. + if (!$field->hasSettledOptions()) { + continue; + } + foreach ($field->options as $option) { if (!$option->selectable()) { throw new FormException(sprintf('Reorder field "%s" allows only plain options - no headings, separators or disabled rows.', $field->id)); diff --git a/src/Engine/Engine.php b/src/Engine/Engine.php index 72d668e0..c85b3f93 100644 --- a/src/Engine/Engine.php +++ b/src/Engine/Engine.php @@ -38,6 +38,21 @@ class Engine { */ protected Deriver $deriver; + /** + * What each field's dynamic option set was last resolved from, and to. + * + * A settling pass that leaves the resolver's whole input as it was - the + * answers and the rest of the run context - leaves the options it produced + * valid, so it is called again only when what it reads has actually changed. + * The rows are remembered alongside that input because a field's options are + * settled state anyone may write: a schema surface resolving them against a + * context of its own retires this memo rather than leaving the engine + * convinced they are still its own. + * + * @var array,run:array{string,bool,string},rows:list<\DrevOps\Tui\Model\Option>}> + */ + protected array $optionMemo = []; + /** * Construct an engine. * @@ -74,7 +89,7 @@ public function collect(array $inputs, Context $context): Answers { [$values, $sources] = $this->resolveAll($fields, $inputs, $context); $values = $this->transformInputs($fields, $values, $sources); [$rules, $pinned] = $this->deriveRules($fields, $sources); - [$active, $values] = $this->stabilize($fields, $values, $rules, $pinned); + [$active, $values] = $this->stabilize($fields, $values, $rules, $pinned, $context, $this->suppliedInputs($sources)); $this->loadQueryOptions($fields, $values, $active); $this->guardInputs($fields, $values, $sources, $active); @@ -147,10 +162,7 @@ protected function loadQueryOptions(array $fields, array $values, array $active) // the field, but headlessly there is nobody to retype the query, so // the collection fails - as an engine error like every other, rather // than as whatever the consumer's backend happened to throw. - throw new EngineException(Translator::t('Could not load options for field "@id": @error', [ - '@id' => $field->id, - '@error' => $throwable->getMessage(), - ]), $throwable->getCode(), $throwable); + throw $this->optionsError($field, $throwable); } foreach ($resolved as $row) { @@ -162,6 +174,106 @@ protected function loadQueryOptions(array $fields, array $values, array $active) } } + /** + * Resolve every dynamic option set against the answers, in place. + * + * A resolver reads the answers, so it is called again whenever they change + * and skipped when they have not - a settling pass that alters nothing costs + * nothing. The resolved set then decides the field's value: one that is no + * longer offered is dropped, a ranking is completed and a toggle falls back, + * so the answers never name an option that is not on offer. A value the + * caller supplied is left alone for the input guard to report, rather than + * disappearing without a word. + * + * @param \DrevOps\Tui\Model\Field[] $fields + * The fields. + * @param array $values + * The current values keyed by field id. + * @param array $active + * Which fields are active, keyed by field id. + * @param \DrevOps\Tui\Handler\Context $context + * The run context the resolvers are called with. + * @param array $supplied + * Field ids whose value was supplied by the caller. + * + * @return array + * The values, reconciled against the resolved option sets. + * + * @throws \DrevOps\Tui\Engine\EngineException + * When a resolver cannot answer. + */ + protected function resolveDynamicOptions(array $fields, array $values, array $active, Context $context, array $supplied): array { + $answers = $this->activeAnswers($fields, $values, $active); + $resolved = new Context($context->directory, $answers, $context->update, $context->version); + + // Everything the resolver is handed, so a second run against another + // directory - or in update mode - is not answered from the memo of the + // first one. + $run = [$context->directory, $context->update, $context->version]; + + foreach ($fields as $field) { + if (!$field->optionsResolver instanceof \Closure) { + continue; + } + + $memo = $this->optionMemo[$field->id] ?? NULL; + if ($memo !== NULL && $memo['answers'] === $answers && $memo['run'] === $run && $memo['rows'] === $field->options) { + continue; + } + + try { + $field->options = Option::resolved(($field->optionsResolver)($resolved)); + } + catch (\Throwable $throwable) { + throw $this->optionsError($field, $throwable); + } + + $this->optionMemo[$field->id] = ['answers' => $answers, 'run' => $run, 'rows' => $field->options]; + + if ($supplied[$field->id] ?? FALSE) { + continue; + } + + $values[$field->id] = $field->reconcileValue($values[$field->id] ?? NULL); + } + + return $values; + } + + /** + * The fields whose value the caller supplied, keyed by field id. + * + * @param array $sources + * The initial source per field id. + * + * @return array + * TRUE for each field answered by a supplied input. + */ + protected function suppliedInputs(array $sources): array { + return array_map(static fn(Source $source): bool => $source === Source::Input, $sources); + } + + /** + * The engine error for consumer option code that could not answer. + * + * @param \DrevOps\Tui\Model\Field $field + * The field whose options were being resolved. + * @param \Throwable $throwable + * What the consumer code threw. + * + * @return \DrevOps\Tui\Engine\EngineException + * The engine error naming the field. + */ + protected function optionsError(Field $field, \Throwable $throwable): EngineException { + // Not every code is an integer - a database driver's SQLSTATE is a string - + // and consumer code decides which exception arrives here, so it is coerced + // rather than allowed to fail the conversion instead of reporting. + return new EngineException(Translator::t('Could not load options for field "@id": @error', [ + '@id' => $field->id, + '@error' => $throwable->getMessage(), + ]), (int) $throwable->getCode(), $throwable); + } + /** * The queries that look up a field's supplied value, one per item. * @@ -206,7 +318,7 @@ public function resolveState(array $inputs, Context $context): array { [$values, $sources] = $this->resolveAll($fields, $inputs, $context); $values = $this->transformInputs($fields, $values, $sources); [$rules, $pinned] = $this->deriveRules($fields, $sources); - [$active, $values] = $this->stabilize($fields, $values, $rules, $pinned); + [$active, $values] = $this->stabilize($fields, $values, $rules, $pinned, $context, $this->suppliedInputs($sources)); $all = array_fill_keys(array_keys($sources), TRUE); @@ -225,14 +337,19 @@ public function resolveState(array $inputs, Context $context): array { * The current values keyed by field id. * @param array $pinned * Derive-ruled field ids that must not be recomputed. + * @param \DrevOps\Tui\Handler\Context $context + * The run context the dynamic option sets resolve against. * * @return array{array,array} * The active map and the settled values. */ - public function settle(array $values, array $pinned): array { + public function settle(array $values, array $pinned, Context $context): array { $fields = $this->form->fields(); - return $this->stabilize($fields, $values, $this->ruleMap($fields), $pinned); + // Nothing here was supplied: an edited value is the live one the user is + // working with, so a narrowed option set reconciles it rather than + // reporting it the way a headless input would be reported. + return $this->stabilize($fields, $values, $this->ruleMap($fields), $pinned, $context, []); } /** @@ -542,11 +659,15 @@ protected function discoverValue(Field $field, Context $context): mixed { * Derive rules keyed by field id. * @param array $pinned * Field ids that must not be re-derived (input or detected). + * @param \DrevOps\Tui\Handler\Context $context + * The run context the dynamic option sets resolve against. + * @param array $supplied + * Field ids whose value was supplied by the caller, keyed by field id. * * @return array{array,array} * The active map and the settled values. */ - protected function stabilize(array $fields, array $values, array $derive_rules, array $pinned): array { + protected function stabilize(array $fields, array $values, array $derive_rules, array $pinned, Context $context, array $supplied): array { $active = []; foreach ($fields as $field) { $active[$field->id] = TRUE; @@ -557,6 +678,10 @@ protected function stabilize(array $fields, array $values, array $derive_rules, // the activation and fix-up interplay. $limit = count($fields) + 2; for ($i = 0; $i <= $limit; $i++) { + // Options resolve first: a set that follows the answers decides what the + // conditions below then see, and what a value is still allowed to be. + $values = $this->resolveDynamicOptions($fields, $values, $active, $context, $supplied); + $derived = $this->deriver->derive($derive_rules, $values, $pinned); $next_active = []; diff --git a/src/Model/Field.php b/src/Model/Field.php index 8c6f797b..70fc5677 100644 --- a/src/Model/Field.php +++ b/src/Model/Field.php @@ -12,11 +12,13 @@ /** * A single question in the configuration model. * - * The definition is immutable except for three concerns resolved once and - * written back: a field may declare an options loader whose result is cached - * (`$options`, `$optionsLoader`), a progress row tracks its live indicator - * (`$progressCurrent`, `$progressLabel`) as its work advances, and the owning - * form definition stamps the field's place in the condition graph + * The definition is immutable except for three resolved concerns written back: + * options a field declares indirectly - through a loader resolved once + * (`$optionsLoader`), a resolver re-run as the answers change + * (`$optionsResolver`) or a query source re-run as the query changes + * (`$optionsSource`) - land in `$options`, a progress row tracks its live + * indicator (`$progressCurrent`, `$progressLabel`) as its work advances, and + * the owning form definition stamps the field's place in the condition graph * (`$conditionalDepth`). Those five properties are the only mutable state. * * @package DrevOps\Tui\Model @@ -203,6 +205,11 @@ final class Field { * Rating only: the caption of a point on the scale, keyed by the point. The * scale is the range in {@see $bounds}; a caption is decoration over it, so * points may be captioned sparsely and an uncaptioned point still answers. + * @param \Closure|null $optionsResolver + * An `fn(Context $context): array` resolving the options + * from the answers collected so far, or NULL for options that do not follow + * them. Unlike a loader it is called again whenever the answers change, so + * one field's choices can narrow by another's answer. */ public function __construct( public readonly string $id, @@ -249,6 +256,7 @@ public function __construct( public readonly array $envAliases = [], public readonly bool $ghost = FALSE, public readonly array $ratingCaptions = [], + public readonly ?\Closure $optionsResolver = NULL, ) { $this->assertEnvNames(); $this->assertRatingCaptions(); @@ -271,6 +279,14 @@ public function __construct( throw new FormException(sprintf('Field "%s" declares a minimum query length but no query source to apply it to.', $this->id)); } + if (($options !== [] || $optionsLoader instanceof \Closure || $this->optionsResolver instanceof \Closure) && !$this->type->supportsOptions()) { + throw new FormException(sprintf('Field "%s" of type "%s" shows no options; only select, search, suggest, toggle and reorder fields have a list.', $this->id, $this->type->value)); + } + + if ($this->optionsResolver instanceof \Closure && ($options !== [] || $optionsLoader instanceof \Closure || $this->optionsSource instanceof \Closure)) { + throw new FormException(sprintf('Field "%s" resolves its options from the answers and declares another set of options as well; the resolved set replaces them, so declare only one.', $this->id)); + } + if ($this->placeholder !== '' && !$this->type->supportsPlaceholder()) { throw new FormException(sprintf('Field "%s" of type "%s" shows no placeholder; only text, number, textarea, password, suggest and search fields have an input buffer to ghost.', $this->id, $this->type->value)); } @@ -432,6 +448,73 @@ public function selectableValues(): array { return Option::selectableValues($this->options); } + /** + * Whether the field's option rows stand as declared. + * + * @return bool + * FALSE while a loader, a resolver or a query source still owes the field + * its rows, so there is nothing yet to count or to check a default against. + */ + public function hasSettledOptions(): bool { + return !$this->optionsLoader instanceof \Closure && !$this->optionsResolver instanceof \Closure && !$this->optionsSource instanceof \Closure; + } + + /** + * Whether the field's option set follows the answers rather than standing. + * + * @return bool + * TRUE when the options are resolved from the collected answers or from a + * live query, so no one list describes the field. + */ + public function hasDynamicOptions(): bool { + return $this->optionsResolver instanceof \Closure || $this->optionsSource instanceof \Closure; + } + + /** + * A value restated against the option set as it now stands. + * + * A choice value outlives the options it was picked from: a set resolved + * from the answers narrows as they change, leaving a value that is no longer + * offered, a ranking that no longer covers the set, or a toggle sitting on a + * state that is gone. This drops what the set no longer holds, completes a + * ranking back to a full permutation and returns a toggle to its first + * state, so a value always describes the options in front of it. + * + * A suggest field's options are hints rather than a closed set, so its value + * is never reconciled against them. + * + * @param mixed $value + * The current value. + * + * @return mixed + * The value the current options can carry. + */ + public function reconcileValue(mixed $value): mixed { + if (!$this->type->supportsOptions() || $this->type === FieldType::Suggest) { + return $value; + } + + $selectable = $this->selectableValues(); + + if ($this->type === FieldType::Reorder) { + return self::canonicalOrder($selectable, self::stringList($value)); + } + + if ($this->isMultiChoice()) { + return array_values(array_filter(self::stringList($value), static fn(string $item): bool => in_array($item, $selectable, TRUE))); + } + + $current = is_scalar($value) ? (string) $value : ''; + + if (in_array($current, $selectable, TRUE)) { + return $current; + } + + // A toggle is always in one of its states, so a value the set no longer + // offers falls back to the first option rather than to nothing. + return $this->type === FieldType::Toggle ? ($selectable[0] ?? '') : ''; + } + /** * The whole message for an empty value on a required field, else NULL. * @@ -547,9 +630,9 @@ public function templateParts(mixed $value): array { */ public function optionError(mixed $value): ?string { // A field that declares no options constrains nothing - but one whose - // options come from a query is constrained by whatever the query answered, - // and answering with nothing means the value does not exist. - if (!$this->type->constrainsToOptions() || ($this->options === [] && !$this->optionsSource instanceof \Closure)) { + // options follow a query or the answers is constrained by whatever they + // resolved to, and resolving to nothing means the value does not exist. + if (!$this->type->constrainsToOptions() || ($this->options === [] && !$this->hasDynamicOptions())) { return NULL; } diff --git a/src/Model/FieldType.php b/src/Model/FieldType.php index 56c34a51..3a26143e 100644 --- a/src/Model/FieldType.php +++ b/src/Model/FieldType.php @@ -123,6 +123,26 @@ public function constrainsToOptions(): bool { ], TRUE); } + /** + * Whether a field of this type shows a list of options at all. + * + * Wider than {@see constrainsToOptions()}: a suggest field's options are + * autocomplete hints rather than a closed set, but it still has a list to + * declare. + * + * @return bool + * TRUE for every type that draws an option list. + */ + public function supportsOptions(): bool { + return in_array($this, [ + self::Select, + self::Search, + self::Suggest, + self::Toggle, + self::Reorder, + ], TRUE); + } + /** * Whether a field of this type may collect several values via `->multiple()`. * diff --git a/src/Render/PanelController.php b/src/Render/PanelController.php index 4cc83925..4d922063 100644 --- a/src/Render/PanelController.php +++ b/src/Render/PanelController.php @@ -8,6 +8,7 @@ use DrevOps\Tui\Answers\Answers; use DrevOps\Tui\Answers\Provenance; use DrevOps\Tui\Engine\Engine; +use DrevOps\Tui\Handler\Context; use DrevOps\Tui\Handler\HandlerRegistry; use DrevOps\Tui\Model\Field; use DrevOps\Tui\Model\FieldType; @@ -218,6 +219,14 @@ class PanelController { * An optional start banner (logo) shown before the interactive loop. * @param string $version * An optional version string shown below the banner. + * @param \DrevOps\Tui\Handler\Context $context + * The run context each settling passes to the closures that read it - the + * option sets resolved from the answers; defaults to a bare context. + * @param \DrevOps\Tui\Engine\Engine|null $engine + * The engine settling the form; NULL builds one over the same form and + * handlers. Passing the one that resolved the initial state keeps a single + * engine over the run, so what it has already resolved is not resolved + * again on the first settling. */ public function __construct( protected FormDefinition $form, @@ -231,6 +240,8 @@ public function __construct( protected bool $clearOnExit = TRUE, protected string $banner = '', protected string $version = '', + protected Context $context = new Context(), + ?Engine $engine = NULL, ) { $this->keymap = $keymap ?? KeyMapManager::create(); $this->externalEditor = $external_editor ?? new ExternalEditor(); @@ -238,7 +249,7 @@ public function __construct( $this->nav = $this->keymap->navigation(); $this->scroller = new Scroller(); $this->navigator = new Navigator(new Panel('hub', $form->title, '', panels: $form->panels, layout: $form->layout)); - $this->engine = new Engine($form, $handlers ?? new HandlerRegistry()); + $this->engine = $engine ?? new Engine($form, $handlers ?? new HandlerRegistry()); // Settle once at construction so the activation map exists before the // first frame and a seeded value set is coherent with the form logic. @@ -851,12 +862,13 @@ protected function handleEditing(Key $key): void { /** * Re-settle the form logic over the current values and clamp the cursor. * - * Runs the engine's settling - derive rules, conditional activation and - * fix-ups - so an interactive change propagates exactly as a headless input - * does, then keeps the cursor inside the possibly-changed item range. + * Runs the engine's settling - option sets resolved from the answers, derive + * rules, conditional activation and fix-ups - so an interactive change + * propagates exactly as a headless input does, then keeps the cursor inside + * the possibly-changed item range. */ protected function resettle(): void { - [$this->active, $this->values] = $this->engine->settle($this->values, $this->pinnedDerives()); + [$this->active, $this->values] = $this->engine->settle($this->values, $this->pinnedDerives(), $this->context); // The refusal describes the values as they were when submit was pressed, so // a change of any kind retires it rather than leaving it to contradict what diff --git a/src/Schema/AgentHelp.php b/src/Schema/AgentHelp.php index 7cd66dac..ee1b5b9d 100644 --- a/src/Schema/AgentHelp.php +++ b/src/Schema/AgentHelp.php @@ -208,7 +208,13 @@ protected function property(Field $field): array { * field is not constrained to a closed set. */ protected function optionValues(Field $field): array { - return $field->type->constrainsToOptions() ? $field->selectableValues() : []; + if (!$field->type->constrainsToOptions()) { + return []; + } + + OptionsResolver::resolve($field, $this->context); + + return $field->selectableValues(); } } diff --git a/src/Schema/OptionsResolver.php b/src/Schema/OptionsResolver.php new file mode 100644 index 00000000..f9f9813b --- /dev/null +++ b/src/Schema/OptionsResolver.php @@ -0,0 +1,51 @@ +optionsResolver instanceof \Closure) { + return; + } + + try { + $field->options = Option::resolved(($field->optionsResolver)($context)); + } + catch (\Throwable) { + // A field's options are settled state that outlives one call, so a set + // resolved for some earlier context is still sitting there. Nothing can + // be said about this one, and saying the last one instead would be a + // description of the wrong form. + $field->options = []; + } + } + +} diff --git a/src/Schema/SchemaGenerator.php b/src/Schema/SchemaGenerator.php index d39088c4..3d77afff 100644 --- a/src/Schema/SchemaGenerator.php +++ b/src/Schema/SchemaGenerator.php @@ -18,7 +18,10 @@ * variables that answer it and the `when`, `derive` and `discover` rules, so * external tooling can drive or validate the form without loading the PHP * declaration. A closure default is resolved against the context (see - * {@see DefaultResolver}) so a computed default advertises a real value. + * {@see DefaultResolver}) so a computed default advertises a real value, and + * options that follow the answers resolve against the same context (see + * {@see OptionsResolver}) and are flagged as `options_dynamic`, so tooling can + * tell a field with no options from one whose options are not fixed. * * @package DrevOps\Tui\Schema */ @@ -64,6 +67,7 @@ public function generate(): array { 'hint' => $field->hint, 'placeholder' => $field->placeholder, 'options' => $this->options($field), + 'options_dynamic' => $field->hasDynamicOptions(), 'default' => DefaultResolver::resolve($field, $this->context), 'required' => $field->required, 'env' => $names->isAdvertisable($field) ? $names->canonical($field) : NULL, @@ -99,6 +103,8 @@ public function generate(): array { * separators, headings and disabled options are excluded. */ protected function options(Field $field): array { + OptionsResolver::resolve($field, $this->context); + $out = []; foreach ($field->options as $option) { diff --git a/src/Schema/SchemaValidator.php b/src/Schema/SchemaValidator.php index b6d5c3a0..e4e9b240 100644 --- a/src/Schema/SchemaValidator.php +++ b/src/Schema/SchemaValidator.php @@ -4,6 +4,7 @@ namespace DrevOps\Tui\Schema; +use DrevOps\Tui\Handler\Context; use DrevOps\Tui\Model\FormDefinition; use DrevOps\Tui\Model\Field; use DrevOps\Tui\Translation\Translator; @@ -12,8 +13,10 @@ * Validates an answer set against the configuration. * * Checks value types, option membership and required questions, and skips - * questions whose `when` condition is not met by the answer set. Returns a - * list of actionable error messages (empty when the set is valid). + * questions whose `when` condition is not met by the answer set. A question + * whose options follow the answers is checked against the set those very + * answers resolve to. Returns a list of actionable error messages (empty when + * the set is valid). * * @package DrevOps\Tui\Schema */ @@ -24,8 +27,11 @@ class SchemaValidator { * * @param \DrevOps\Tui\Model\FormDefinition $form * The configuration to validate against. + * @param \DrevOps\Tui\Handler\Context $context + * The run context an options resolver is evaluated against, its answers + * replaced by the set under validation; defaults to an empty context. */ - public function __construct(protected FormDefinition $form) { + public function __construct(protected FormDefinition $form, protected Context $context = new Context()) { } /** @@ -62,6 +68,12 @@ public function validate(array $answers): array { continue; } + // Options that follow the answers describe what this very answer set + // allows, so they are settled against it - carried on the run context, so + // a resolver reading the directory or the update flag sees what it would + // see during collection - before membership is checked. + OptionsResolver::resolve($field, new Context($this->context->directory, $answers, $this->context->update, $this->context->version)); + $error = $this->validateValue($field, $answers[$field->id]); if ($error !== NULL) { $errors[] = $error; diff --git a/src/Tui.php b/src/Tui.php index 040b4cb3..692a78e5 100644 --- a/src/Tui.php +++ b/src/Tui.php @@ -517,10 +517,12 @@ public function controller(array $options, string $theme = '', string $banner = // Restore this facade's language before rendering (see collect()). Translator::setShared($this->translator); + $context = $this->context($directory, $update, $version); + // The full state, not collect()'s active-only answers: an inactive field // keeps its settled value, so a condition satisfied mid-session surfaces // the field with its default rather than an empty value. - [$values, $provenance] = $this->engine->resolveState([], $this->context($directory, $update, $version)); + [$values, $provenance] = $this->engine->resolveState([], $context); $banner_text = $banner !== '' ? $banner : $this->form->banner; @@ -535,6 +537,8 @@ public function controller(array $options, string $theme = '', string $banner = clearOnExit: $this->clearOnExit, banner: $banner_text, version: $version, + context: $context, + engine: $this->engine, ); } @@ -596,12 +600,15 @@ public function agentHelp(?Context $context = NULL): string { * * @param array $answers * The answers to validate. + * @param \DrevOps\Tui\Handler\Context|null $context + * The context an options resolver is evaluated against, its answers + * replaced by the ones under validation; NULL uses an empty context. * * @return list * The validation errors (empty when valid). */ - public function validate(array $answers): array { - return (new SchemaValidator($this->form))->validate($answers); + public function validate(array $answers, ?Context $context = NULL): array { + return (new SchemaValidator($this->form, $context ?? new Context()))->validate($answers); } /** diff --git a/tests/phpunit/Unit/DynamicOptionsTest.php b/tests/phpunit/Unit/DynamicOptionsTest.php new file mode 100644 index 00000000..de943907 --- /dev/null +++ b/tests/phpunit/Unit/DynamicOptionsTest.php @@ -0,0 +1,578 @@ +> + */ + protected const array CATALOG = [ + 'fruit' => ['apple' => 'Apple', 'banana' => 'Banana', 'cherry' => 'Cherry'], + 'vegetable' => ['carrot' => 'Carrot', 'potato' => 'Potato', 'tomato' => 'Tomato'], + ]; + + /** + * The contexts the scripted resolver was called with, in order. + * + * @var list<\DrevOps\Tui\Handler\Context> + */ + protected array $contexts = []; + + public function testResolvesTheOptionsOfTheAnsweredCategory(): void { + $answers = (new Tui($this->form()))->collect('{"category":"vegetable","item":"carrot"}'); + + $this->assertSame('carrot', $answers->value('item')); + $this->assertSame('vegetable', $this->lastContext()->answers['category']); + } + + public function testRejectsValueTheResolvedSetDoesNotHold(): void { + // Apple is a real option - just not one this category resolves to. + $this->expectException(EngineException::class); + $this->expectExceptionMessageMatches('/not one of: carrot, potato, tomato/'); + + (new Tui($this->form()))->collect('{"category":"vegetable","item":"apple"}'); + } + + public function testRejectsValueWhenTheSetResolvesToNothing(): void { + // With no list to offer, naming the value is all that can honestly be said. + $this->expectException(EngineException::class); + $this->expectExceptionMessageMatches('/"apple" was not found/'); + + (new Tui($this->form(static fn(): array => [])))->collect('{"category":"fruit","item":"apple"}'); + } + + public function testResolverReadsTheWholeRunContext(): void { + (new Tui($this->form()))->collect('{"category":"fruit"}', 'orchard', TRUE, '2.0'); + + $context = $this->lastContext(); + $this->assertSame('orchard', $context->directory); + $this->assertTrue($context->update); + $this->assertSame('2.0', $context->version); + } + + public function testResolverReadsDerivedAnswers(): void { + $form = Form::create('Order')->panel('order', 'New order', function (PanelBuilder $p): void { + $p->text('label', 'Order label')->default('Vegetable'); + $p->text('category', 'Category')->derive(new Derive('{{label}}', transform: 'lower')); + $p->select('item', 'Item')->options($this->resolver()); + }); + + // The derived category settles before the options resolve, so the resolver + // sees the computed value rather than the empty one it started as. + $answers = (new Tui($form))->collect('{"item":"carrot"}'); + + $this->assertSame('carrot', $answers->value('item')); + } + + public function testDefaultOutsideTheResolvedSetIsDropped(): void { + $form = $this->form(NULL, static fn(FieldBuilder $field): FieldBuilder => $field->default('apple')); + + $answers = (new Tui($form))->collect('{"category":"vegetable"}'); + + $this->assertSame('', $answers->value('item')); + } + + public function testDefaultInsideTheResolvedSetStands(): void { + $form = $this->form(NULL, static fn(FieldBuilder $field): FieldBuilder => $field->default('apple')); + + $answers = (new Tui($form))->collect('{"category":"fruit"}'); + + $this->assertSame('apple', $answers->value('item')); + } + + public function testMultipleKeepsOnlyTheValuesTheSetStillHolds(): void { + $form = Form::create('Order')->panel('order', 'New order', function (PanelBuilder $p): void { + $p->select('category', 'Category')->options(['fruit' => 'Fruit', 'vegetable' => 'Vegetable'])->default('fruit'); + $p->select('basket', 'Basket')->multiple()->default(['apple', 'carrot', 'cherry'])->options($this->resolver()); + }); + + $answers = (new Tui($form))->collect('{"category":"fruit"}'); + + $this->assertSame(['apple', 'cherry'], $answers->value('basket')); + } + + public function testRankingIsCompletedToTheResolvedOptions(): void { + $form = Form::create('Order')->panel('order', 'New order', function (PanelBuilder $p): void { + $p->select('category', 'Category')->options(['fruit' => 'Fruit', 'vegetable' => 'Vegetable'])->default('vegetable'); + $p->reorder('ranking', 'Ranking')->options($this->resolver()); + }); + + // A reorder is always a full permutation, so the resolved set becomes the + // ranking even though the field was declared without one. + $answers = (new Tui($form))->collect('{}'); + + $this->assertSame(['carrot', 'potato', 'tomato'], $answers->value('ranking')); + } + + public function testToggleFallsBackToTheFirstResolvedOption(): void { + $form = Form::create('Order')->panel('order', 'New order', function (PanelBuilder $p): void { + $p->select('category', 'Category')->options(['fruit' => 'Fruit', 'vegetable' => 'Vegetable'])->default('vegetable'); + $p->toggle('item', 'Item')->options($this->resolver()); + }); + + // A toggle is always in one of its states, and it has none until the + // resolver hands it some. + $answers = (new Tui($form))->collect('{}'); + + $this->assertSame('carrot', $answers->value('item')); + } + + public function testSuggestKeepsValueTheResolvedHintsDoNotHold(): void { + $form = Form::create('Order')->panel('order', 'New order', function (PanelBuilder $p): void { + $p->select('category', 'Category')->options(['fruit' => 'Fruit', 'vegetable' => 'Vegetable'])->default('fruit'); + $p->suggest('item', 'Item')->options($this->resolver()); + }); + + // A suggest field's options are hints, never a closed set, so a value + // outside them is the user's own rather than a mistake. + $answers = (new Tui($form))->collect('{"item":"Quince"}'); + + $this->assertSame('Quince', $answers->value('item')); + } + + public function testResolverReturningSomethingElseDegradesToNoOptions(): void { + $form = $this->form(static fn(): mixed => 'not a map'); + + $this->expectException(EngineException::class); + $this->expectExceptionMessageMatches('/"apple" was not found/'); + + (new Tui($form))->collect('{"category":"fruit","item":"apple"}'); + } + + public function testThrowingResolverBecomesAnEngineError(): void { + $form = $this->form(static function (): array { + throw new \RuntimeException('The pantry is unreachable.'); + }); + + $this->expectException(EngineException::class); + $this->expectExceptionMessageMatches('/Could not load options for field "item": The pantry is unreachable\./'); + + (new Tui($form))->collect('{"category":"fruit"}'); + } + + public function testInteractiveListNarrowsToTheChosenCategory(): void { + $tester = $this->tester($this->form()); + // Open the category, step to Vegetable, take it, then open the item. + $tester->run($this->enter(), $this->enter(), $this->down(), $this->enter(), $this->down(), $this->enter()); + + $display = $tester->display(); + $this->assertStringContainsString('Carrot', $display); + $this->assertStringNotContainsString('Apple', $display); + } + + public function testInteractiveSelectionIsDroppedOnceTheSetNarrows(): void { + $tester = $this->tester($this->form()); + // Take Apple under Fruit, then go back and switch the category: the choice + // the new category does not offer is not left standing in the answers. + $answers = $tester->run( + $this->enter(), + $this->down(), + $this->enter(), + $this->enter(), + $this->up(), + $this->enter(), + $this->down(), + $this->enter(), + ); + + $this->assertSame('vegetable', $answers->value('category')); + $this->assertSame('', $answers->value('item')); + } + + public function testCallbackAskingForNothingStillLoadsOnce(): void { + $calls = 0; + $form = Form::create('Order')->panel('order', 'New order', function (PanelBuilder $p) use (&$calls): void { + $p->select('category', 'Category')->options(['fruit' => 'Fruit', 'vegetable' => 'Vegetable'])->default('fruit'); + $p->select('item', 'Item')->options(function () use (&$calls): array { + $calls++; + + return ['apple' => 'Apple']; + }); + }); + + // The callback asks for no context, so it is the loader it has always been: + // resolved once when the panel opens, and left alone when an answer it + // cannot read changes under it. + $tester = $this->tester($form); + $tester->run($this->enter(), $this->enter(), $this->down(), $this->enter(), $this->down(), $this->enter()); + + $this->assertSame(1, $calls); + $this->assertStringContainsString('Apple', $tester->display()); + } + + public function testResolverIsNotCalledAgainWhileTheAnswersStand(): void { + $tester = $this->tester($this->form()); + // Moving the cursor and opening an editor changes no answer, so the options + // in front of the user are still the ones already resolved. + $tester->run($this->down(), $this->down(), $this->up(), $this->enter()); + + $this->assertCount(1, $this->contexts); + } + + public function testValidatorChecksMembershipAgainstTheResolvedSet(): void { + $tui = new Tui($this->form()); + + $this->assertSame([], $tui->validate(['category' => 'vegetable', 'item' => 'carrot'])); + $this->assertSame(['Question "item": value "apple" is not one of: carrot, potato, tomato.'], $tui->validate(['category' => 'vegetable', 'item' => 'apple'])); + } + + public function testValidatorCarriesTheRunContextToTheResolver(): void { + // The resolver sees what it would see during collection, so one reading the + // directory or the update flag does not judge a value against a set that + // only a bare context produces. + (new Tui($this->form()))->validate(['category' => 'vegetable', 'item' => 'carrot'], new Context('orchard', [], TRUE, '2.0')); + + $context = $this->lastContext(); + $this->assertSame('orchard', $context->directory); + $this->assertTrue($context->update); + $this->assertSame('2.0', $context->version); + $this->assertSame('vegetable', $context->answers['category']); + } + + public function testResolverIsCalledAgainForAnotherRunContext(): void { + $tui = new Tui($this->form()); + + // Same answers, another directory: a resolver reading the context has a + // different question to answer, so the first run's options are not reused. + $tui->collect('{"category":"fruit"}', 'orchard'); + $tui->collect('{"category":"fruit"}', 'market'); + + $this->assertSame(['orchard', 'market'], array_map(static fn(Context $context): string => $context->directory, $this->contexts)); + } + + public function testFailedResolutionLeavesNoOptionsForSchemaSurfaces(): void { + // The first context resolves; the second cannot. Its description must not + // fall back to the set the first one produced. + $form = $this->form(static function (Context $context): array { + if (($context->answers['category'] ?? '') === 'vegetable') { + throw new \RuntimeException('The pantry is unreachable.'); + } + + return self::CATALOG['fruit']; + }); + + $tui = new Tui($form); + $this->assertSame(['apple', 'banana', 'cherry'], $this->schemaOptions($tui->schema(new Context(answers: ['category' => 'fruit'])), 'item')); + $this->assertSame([], $this->schemaOptions($tui->schema(new Context(answers: ['category' => 'vegetable'])), 'item')); + } + + public function testSchemaResolvesTheOptionsOfTheGivenContext(): void { + $schema = (new Tui($this->form()))->schema(new Context(answers: ['category' => 'vegetable'])); + + $this->assertSame(['carrot', 'potato', 'tomato'], $this->schemaOptions($schema, 'item')); + $this->assertTrue($this->schemaFlag($schema, 'item', 'options_dynamic')); + $this->assertFalse($this->schemaFlag($schema, 'category', 'options_dynamic')); + } + + public function testSchemaFlagsOptionsThatFollowTheQuery(): void { + $form = Form::create('Order')->panel('order', 'New order', function (PanelBuilder $p): void { + $p->search('veg', 'Vegetable')->optionsFrom(static fn(string $query): array => []); + }); + + $this->assertTrue($this->schemaFlag((new Tui($form))->schema(), 'veg', 'options_dynamic')); + } + + public function testAgentHelpEnumeratesTheResolvedValues(): void { + $help = (new Tui($this->form()))->agentHelp(new Context(answers: ['category' => 'vegetable'])); + + $this->assertStringContainsString('carrot', $help); + $this->assertStringNotContainsString('apple', $help); + } + + public function testReconcilingLeavesNonChoiceValueAlone(): void { + $field = new Field('name', 'Order name', '', FieldType::Text, 'Pear'); + + $this->assertSame('Pear', $field->reconcileValue('Pear')); + } + + #[DataProvider('dataProviderRejectedDeclarationFailsWhenTheFormIsBuilt')] + public function testRejectedDeclarationFailsWhenTheFormIsBuilt(\Closure $declare, string $message): void { + $this->expectException(FormException::class); + $this->expectExceptionMessageMatches($message); + + Form::create('Order')->panel('order', 'New order', $declare)->build(); + } + + /** + * Declarations an options resolver cannot be combined with. + * + * @return \Iterator + * The panel declaration and the expected message pattern, per case. + */ + public static function dataProviderRejectedDeclarationFailsWhenTheFormIsBuilt(): \Iterator { + $resolver = static fn(Context $context): array => []; + + yield 'a resolver on a type with no option list' => [ + static function (PanelBuilder $p) use ($resolver): void { + $p->text('item', 'Item')->options($resolver); + }, + '/of type "text" shows no options/', + ]; + + yield 'a fixed list on a type with no option list' => [ + static function (PanelBuilder $p): void { + $p->number('quantity', 'Quantity')->options(['1' => 'One']); + }, + '/of type "number" shows no options/', + ]; + + yield 'a loader on a type with no option list' => [ + static function (PanelBuilder $p): void { + $p->text('item', 'Item')->options(static fn(): array => ['apple' => 'Apple']); + }, + '/of type "text" shows no options/', + ]; + + yield 'alongside static options' => [ + static function (PanelBuilder $p) use ($resolver): void { + $p->select('item', 'Item')->options(['apple' => 'Apple'])->options($resolver); + }, + '/declare only one/', + ]; + + yield 'alongside an option loader' => [ + static function (PanelBuilder $p) use ($resolver): void { + $p->select('item', 'Item')->options(static fn(): array => ['apple' => 'Apple'])->options($resolver); + }, + '/declare only one/', + ]; + + yield 'alongside a query source' => [ + static function (PanelBuilder $p) use ($resolver): void { + $p->search('item', 'Item')->optionsFrom(static fn(string $query): array => [])->options($resolver); + }, + '/declare only one/', + ]; + } + + /** + * A single-panel order form whose item options follow the chosen category. + * + * @param \Closure|null $answer + * The `fn (Context $context): mixed` the resolver answers with; NULL reads + * the catalog of the answered category. + * @param \Closure|null $declare + * A `fn (FieldBuilder): FieldBuilder` adding further declarations. + * + * @return \DrevOps\Tui\Builder\Form + * The form. + */ + protected function form(?\Closure $answer = NULL, ?\Closure $declare = NULL): Form { + return Form::create('Order')->panel('order', 'New order', function (PanelBuilder $p) use ($answer, $declare): void { + $p->select('category', 'Category')->options(['fruit' => 'Fruit', 'vegetable' => 'Vegetable'])->default('fruit'); + $field = $p->select('item', 'Item')->options($this->resolver($answer)); + + if ($declare instanceof \Closure) { + $declare($field); + } + }); + } + + /** + * An options resolver that records the context before it answers. + * + * @param \Closure|null $answer + * The `fn (Context $context): mixed` to answer with; NULL reads the catalog + * of the answered category. + * + * @return \Closure + * The recording resolver. + */ + protected function resolver(?\Closure $answer = NULL): \Closure { + return function (Context $context) use ($answer): mixed { + $this->contexts[] = $context; + + if ($answer instanceof \Closure) { + return $answer($context); + } + + $category = $context->answers['category'] ?? ''; + + return is_string($category) ? (self::CATALOG[$category] ?? []) : []; + }; + } + + /** + * The option values a generated schema advertises for a prompt. + * + * @param array $schema + * The generated schema. + * @param string $id + * The prompt id. + * + * @return list + * The advertised option values, in order. + */ + protected function schemaOptions(array $schema, string $id): array { + $options = $this->schemaFor($schema, $id)['options'] ?? NULL; + + if (!is_array($options)) { + $this->fail(sprintf('The prompt "%s" carries no options.', $id)); + } + + $values = []; + + foreach ($options as $option) { + $value = is_array($option) ? ($option['value'] ?? NULL) : NULL; + + if (!is_string($value)) { + $this->fail(sprintf('The prompt "%s" carries an option with no value.', $id)); + } + + $values[] = $value; + } + + return $values; + } + + /** + * A boolean a generated schema carries for a prompt. + * + * @param array $schema + * The generated schema. + * @param string $id + * The prompt id. + * @param string $key + * The key of the boolean. + * + * @return bool + * The advertised value. + */ + protected function schemaFlag(array $schema, string $id, string $key): bool { + $flag = $this->schemaFor($schema, $id)[$key] ?? NULL; + + if (!is_bool($flag)) { + $this->fail(sprintf('The prompt "%s" carries no "%s" flag.', $id, $key)); + } + + return $flag; + } + + /** + * The prompt entry a generated schema carries for a field. + * + * @param array $schema + * The generated schema. + * @param string $id + * The prompt id. + * + * @return array + * The prompt entry. + */ + protected function schemaFor(array $schema, string $id): array { + $prompts = $schema['prompts'] ?? NULL; + + if (!is_array($prompts)) { + $this->fail('The schema carries no prompts.'); + } + + foreach ($prompts as $prompt) { + if (is_array($prompt) && ($prompt['id'] ?? NULL) === $id) { + return $prompt; + } + } + + $this->fail(sprintf('The schema carries no prompt "%s".', $id)); + } + + /** + * The context of the resolver's most recent call. + * + * @return \DrevOps\Tui\Handler\Context + * The context. + */ + protected function lastContext(): Context { + $this->assertNotEmpty($this->contexts); + + return $this->contexts[count($this->contexts) - 1]; + } + + /** + * A tester rendering the form deterministically at a workable height. + * + * @param \DrevOps\Tui\Builder\Form $form + * The form. + * + * @return \DrevOps\Tui\Testing\TuiTester + * The tester. + */ + protected function tester(Form $form): TuiTester { + return (new TuiTester($form))->rows(16); + } + + /** + * The Enter key, as its own read. + * + * @return \DrevOps\Tui\Input\Key + * The key. + */ + protected function enter(): Key { + return Key::named(KeyName::Enter); + } + + /** + * The Down key, as its own read. + * + * @return \DrevOps\Tui\Input\Key + * The key. + */ + protected function down(): Key { + return Key::named(KeyName::Down); + } + + /** + * The Up key, as its own read. + * + * @return \DrevOps\Tui\Input\Key + * The key. + */ + protected function up(): Key { + return Key::named(KeyName::Up); + } + +} diff --git a/tests/phpunit/Unit/Engine/EngineConditionalTest.php b/tests/phpunit/Unit/Engine/EngineConditionalTest.php index 4d28bb9b..a3267d3c 100644 --- a/tests/phpunit/Unit/Engine/EngineConditionalTest.php +++ b/tests/phpunit/Unit/Engine/EngineConditionalTest.php @@ -158,10 +158,10 @@ public function testResolveStateKeepsInactiveFields(): void { public function testSettleReappliesFormLogic(): void { $engine = $this->gatedEngine(); - [$active] = $engine->settle(['theme' => 'custom', 'custom_theme' => 'mytheme'], []); + [$active] = $engine->settle(['theme' => 'custom', 'custom_theme' => 'mytheme'], [], new Context()); $this->assertSame(['theme' => TRUE, 'custom_theme' => TRUE], $active); - [$active] = $engine->settle(['theme' => 'olivero', 'custom_theme' => 'mytheme'], []); + [$active] = $engine->settle(['theme' => 'olivero', 'custom_theme' => 'mytheme'], [], new Context()); $this->assertFalse($active['custom_theme']); } diff --git a/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php b/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php index f045a854..d8aaf881 100644 --- a/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php +++ b/tests/phpunit/Unit/Schema/SchemaGeneratorTest.php @@ -46,6 +46,7 @@ public function testGenerate(): void { ['value' => 'standard', 'label' => 'Standard', 'description' => 'Std'], ['value' => 'minimal', 'label' => 'Minimal', 'description' => ''], ], + 'options_dynamic' => FALSE, 'default' => 'standard', 'required' => TRUE, 'env' => NULL, @@ -73,6 +74,7 @@ public function testGenerate(): void { 'hint' => 'Leave empty to follow the profile.', 'placeholder' => 'E.g. Golden Beetroot', 'options' => [], + 'options_dynamic' => FALSE, 'default' => '', 'required' => FALSE, 'env' => NULL, @@ -100,6 +102,7 @@ public function testGenerate(): void { 'hint' => '', 'placeholder' => '', 'options' => [], + 'options_dynamic' => FALSE, 'default' => 0, 'required' => FALSE, 'env' => NULL, @@ -127,6 +130,7 @@ public function testGenerate(): void { 'hint' => '', 'placeholder' => '', 'options' => [], + 'options_dynamic' => FALSE, 'default' => '', 'required' => FALSE, 'env' => NULL, @@ -171,6 +175,7 @@ public function testDescribesTemplateShape(): void { 'hint' => '', 'placeholder' => '', 'options' => [], + 'options_dynamic' => FALSE, 'default' => 'valley-a', 'required' => FALSE, 'env' => NULL, @@ -219,6 +224,7 @@ public function testExcludesNonSelectableOptions(): void { 'options' => [ ['value' => 'standard', 'label' => 'Standard', 'description' => ''], ], + 'options_dynamic' => FALSE, 'default' => '', 'required' => FALSE, 'env' => NULL,