inquirex 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f99f7adba97c905c55490b57467fe062251719bbb98d698fe49e6dd04360fcf7
4
- data.tar.gz: 2c6850590153b96ee376ddaa0314c2de3cf729f6ea2d7c8f3d0ccb20d2359683
3
+ metadata.gz: 67e5f1389368dbb7416aa60a83a7cf613997e72d728d4fb10bd8290741c83901
4
+ data.tar.gz: 105ed41192f4208d7a856e2c12edae0403343f19af45faeae7d557e5bd7048ef
5
5
  SHA512:
6
- metadata.gz: 18b95d28461f971551997bb177c6ead5c4165562f1d627460d58656440a0884bf0972eef2d97a31e3883f34720349be82e24e2fa567b7181eea0f14361182355
7
- data.tar.gz: ed93da41fe8d2f66098b0165224e472d794d6f7ab9313dd8ec9a8a80b0d299ef5623d957361d83a27680b1ce3c025dd294fc6690c855c02a32df90348726718c
6
+ metadata.gz: 9b2a1379ab0b37450c4621b03068939e63a1559dc5dec0d91de03d3819ba765d93f1c2587313738f5bd93bf0a9bbdfb8de5061f715d563407a49b306d7819d6d
7
+ data.tar.gz: 66313dc2a02d2ee469c8076e752ca73b5cd15b857ae475f91e566eea0f18f1756f8dfdddb87b9e66ce7fbde58a5871fc30fcd56bf30f9d812f3dc3abbfae4883
data/CHANGELOG.md CHANGED
@@ -1,22 +1,23 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.7.0] - 2026-07-21
4
+
5
+ - `required false` DSL builder method on collecting steps (`ask`, `confirm`): marks a question as optional so widgets render a small Skip control. Default remains `required true`, so every existing flow is unchanged.
6
+ - `Node#required?` predicate; step JSON gains `"required": false` (omitted when true), round-tripping through `to_json` / `from_json`.
7
+ - `Engine#skip` — user-initiated skip of the current optional step: records the step's `default` (when declared) into the answers and accumulators exactly as if answered, marks the step id in the new `Engine#skipped` list, and advances through transitions like `#answer`. Raises `Errors::RequiredStepError` (new) on a required step, `NonCollectingStepError` on a display step.
8
+ - Skip without a default writes no answers entry (a missing key and `nil` branch identically in rules; the "declined" signal lives in `skipped`).
9
+ - `Engine#skipped` / `Engine#skipped?(step_id)` distinguish default-by-skip values from user-provided answers; the list survives `to_state` / `from_state` (string→symbol normalized) and is merged into `Engine#answers_with_metadata` under `:skipped`.
10
+ - Steps elided automatically by `skip_if` rules are NOT marked as skipped — auto-elision is flow logic, `#skip` is a user action (see docs/design/required-and-skip.md).
11
+
3
12
  ## [0.6.0] - 2026-07-19
4
13
 
5
14
  - Post-completion `action` DSL verb with an extensible effect registry
6
- - `send_email` effect: builds `Mail::Message` objects (multipart text/HTML)
7
- from `{{field}}` templates into `Answers#outbox`; delivery is the host
8
- application's responsibility. The `mail` gem is a soft dependency.
15
+ - `send_email` effect: builds `Mail::Message` objects (multipart text/HTML) from `{{field}}` templates into `Answers#outbox`; delivery is the host application's responsibility. The `mail` gem is a soft dependency.
9
16
  - `run { |answers, outbox| ... }` escape-hatch effect (stripped from JSON)
10
- - `webhook` effect: POSTs the answers envelope to a static https URL whose
11
- host must be covered by the new top-level `allowed_domains` declaration;
12
- enforcement runs in `Definition#validate!`, so tampered JSON definitions
13
- fail at rehydration
17
+ - `webhook` effect: POSTs the answers envelope to a static https URL whose host must be covered by the new top-level `allowed_domains` declaration; enforcement runs in `Definition#validate!`, so tampered JSON definitions fail at rehydration
14
18
  - `Inquirex::Actions::Runner` and `Inquirex::Actions.run(definition, answers)`
15
19
  - Actions serialize to/from JSON with their rule gates
16
- - `CompletionMetadata` (OpenStruct; `engine` and `engine_version` required)
17
- stamped by the engine at flow completion, enrichable by front-ends via the
18
- new `Engine#after_completion` hook, persisted in engine state, and merged
19
- into answers by `Engine#answers_with_metadata`
20
+ - `CompletionMetadata` (OpenStruct; `engine` and `engine_version` required) stamped by the engine at flow completion, enrichable by front-ends via the new `Engine#after_completion` hook, persisted in engine state, and merged into answers by `Engine#answers_with_metadata`
20
21
 
21
22
  ## [0.1.0] - 2026-04-13
22
23
 
data/README.md CHANGED
@@ -31,7 +31,7 @@ It provides a rich DSL for creating dynamic user intake forms, with complexity r
31
31
  >
32
32
  > For technically inclined, Inquirex turns user forms into a directed graph, where nodes are either questions or statements (or UI transitions), while edges are AST-based logical conditions that can be stacked and joined in arbitrarily complex ways, allowing you to move from one question to any other based on the previous answer. See the details below.
33
33
 
34
- ***Don't want to deal with figuring it out? Head to [Qualified.At](https://qualified.at/onboarding) and walk through the demo onboarding form, that exists specifically to show you how quickly you can have the same conceptually on your site, tailored to YOUR users.***
34
+ ***Don't want to deal with figuring it out? Head to [Qualified.At](https://qualified.at) and walk through the demo onboarding form, that exists specifically to show you how quickly you can have the same conceptually on your site, tailored to YOUR users.***
35
35
 
36
36
  ## Summary
37
37
 
@@ -173,6 +173,7 @@ engine.finished? # => true
173
173
  - `text "..."` for display steps
174
174
  - `options [...]` or `options key: "Label"` for enum-style inputs
175
175
  - `default value` or `default { |answers| ... }`
176
+ - `required false` (marks a question optional — renderers show a Skip control; see [Optional Questions and Skipping](#optional-questions-and-skipping))
176
177
  - `skip_if rule`
177
178
  - `transition to: :next_step, if_rule: rule, requires_server: false`
178
179
  - `compute { |answers| ... }` (accepted by the DSL as a server-side hook; currently omitted from runtime JSON)
@@ -432,14 +433,11 @@ Behavior:
432
433
 
433
434
  - Use `answer(value)` on collecting steps
434
435
  - Use `advance` on display steps
436
+ - Use `skip` on optional (`required false`) collecting steps the user declines (see [Optional Questions and Skipping](#optional-questions-and-skipping))
435
437
  - Use `finished?` to detect completion
436
438
  - Use `total(:price)` / `totals` to read running totals
437
439
  - Use `to_state` / `.from_state` for persistence/resume (totals included)
438
- - Use `prefill!(hash)` to merge externally-supplied answers into the state,
439
- e.g. fields extracted by an LLM from a free-text answer (see
440
- [inquirex-llm](#extension-gems)). Existing answers are preserved; `nil`
441
- and empty values are ignored so they don't spuriously satisfy
442
- `not_empty` rules. The engine auto-advances past any newly-skippable step.
440
+ - Use `prefill!(hash)` to merge externally-supplied answers into the state, e.g. fields extracted by an LLM from a free-text answer (see [inquirex-llm](#extension-gems)). Existing answers are preserved; `nil` and empty values are ignored so they don't spuriously satisfy `not_empty` rules. The engine auto-advances past any newly-skippable step.
443
441
 
444
442
  ```ruby
445
443
  engine = Inquirex::Engine.new(definition)
@@ -466,12 +464,64 @@ Pass a custom adapter to the engine:
466
464
  engine = Inquirex::Engine.new(definition, validator: my_validator)
467
465
  ```
468
466
 
467
+ ## Optional Questions and Skipping
468
+
469
+ Every collecting step is **required by default** — the flow will not advance without an answer. Declare `required false` to make a question optional: renderers (the JS widget, the TTY adapter, the qualified.at wizard) show a small **Skip** control next to the input, and a user who declines the question moves on via `Engine#skip`.
470
+
471
+ ```ruby
472
+ ask :dependents do
473
+ type :integer
474
+ question "How many dependents?"
475
+ required false # renders a Skip control
476
+ default 0 # what a skip records into the answers
477
+ transition to: :adult_path, if_rule: greater_than(:dependents, 0)
478
+ transition to: :done
479
+ end
480
+ ```
481
+
482
+ When the user skips:
483
+
484
+ - **With a `default`** — the default is recorded into `answers[:dependents]` and contributes to [accumulators](#accumulators) exactly as if the user had submitted it. Transition rules evaluate against the default, so branching stays deterministic.
485
+ - **Without a `default`** — no answers entry is written at all (not even `nil`). Rules read a missing key as `nil`, so branching behaves as if the question were unanswered.
486
+ - Either way the step id lands in `engine.skipped`, which is how a defaulted-by-skip value is **distinguished from an answer the user actually provided**:
487
+
488
+ ```ruby
489
+ engine.skip # user pressed Skip on :dependents
490
+ engine.answers[:dependents] # => 0 (the default)
491
+ engine.skipped # => [:dependents]
492
+ engine.skipped?(:dependents) # => true
493
+ ```
494
+
495
+ Guard rails:
496
+
497
+ - `skip` on a **required** step raises `Errors::RequiredStepError`
498
+ - `skip` on a **display** step raises `Errors::NonCollectingStepError` (use `advance`)
499
+ - `skip` after the flow finished raises `Errors::AlreadyFinishedError`
500
+
501
+ The `skipped` list survives `to_state` / `.from_state` round-trips (string keys from JSON are normalized back to symbols), and `engine.answers_with_metadata` merges it into the answers under `:skipped` — so post-completion actions (webhook payloads, email templates) and API consumers see which values were defaults-by-skip.
502
+
503
+ On the wire, step JSON carries `"required": false` (omitted when true, like other defaults):
504
+
505
+ ```json
506
+ {
507
+ "dependents": {
508
+ "verb": "ask",
509
+ "type": "integer",
510
+ "question": "How many dependents?",
511
+ "default": 0,
512
+ "required": false,
513
+ "transitions": [{ "to": "done" }]
514
+ }
515
+ }
516
+ ```
517
+
518
+ > [!NOTE]
519
+ >
520
+ > **`skip_if` is not the same thing.** A `skip_if` rule is *flow logic*: the definition elides the step from the path automatically, no default kicks in, and the step is **not** added to `engine.skipped` — it was never presented, so the user cannot have declined it. `Engine#skip` is a *user action* on a question that was presented. See [`docs/design/required-and-skip.md`](docs/design/required-and-skip.md) for the full design.
521
+
469
522
  ## Completion Metadata
470
523
 
471
- When a flow finishes, the engine guarantees a `CompletionMetadata` — an
472
- OpenStruct describing how the answers were collected. Only `engine` and
473
- `engine_version` are required members; rendering front-ends attach richer
474
- environment details from an `after_completion` hook:
524
+ When a flow finishes, the engine guarantees a `CompletionMetadata` — an OpenStruct describing how the answers were collected. Only `engine` and `engine_version` are required members; rendering front-ends attach richer environment details from an `after_completion` hook:
475
525
 
476
526
  ```ruby
477
527
  engine.after_completion do |eng|
@@ -485,21 +535,11 @@ engine.after_completion do |eng|
485
535
  end
486
536
  ```
487
537
 
488
- If no hook supplies one, the engine stamps the minimal core version
489
- (`engine: "inquirex"`, `engine_version: Inquirex::VERSION`). The metadata
490
- persists through `Engine#to_state` / `Engine.from_state`, and
491
- `engine.answers_with_metadata` merges it into the answers hash under
492
- `:completion_metadata` — which also makes it available to post-completion
493
- actions: webhook payloads carry it, and email templates can interpolate
494
- `{{completion_metadata.engine}}`.
538
+ If no hook supplies one, the engine stamps the minimal core version (`engine: "inquirex"`, `engine_version: Inquirex::VERSION`). The metadata persists through `Engine#to_state` / `Engine.from_state`, and `engine.answers_with_metadata` merges it into the answers hash under `:completion_metadata` — which also makes it available to post-completion actions: webhook payloads carry it, and email templates can interpolate `{{completion_metadata.engine}}`.
495
539
 
496
540
  ## Post-Completion Actions
497
541
 
498
- After all questions are answered, `action` declarations run server-side with
499
- the collected answers. The flagship effect is `send_email`, which **builds**
500
- `Mail::Message` objects (the same object ActionMailer wraps) and attaches them
501
- to `answers.outbox` — **nothing is delivered**; the host application decides
502
- how and when to send.
542
+ After all questions are answered, `action` declarations run server-side with the collected answers. The flagship effect is `send_email`, which **builds** `Mail::Message` objects (the same object ActionMailer wraps) and attaches them to `answers.outbox` — **nothing is delivered**; the host application decides how and when to send.
503
543
 
504
544
  ```ruby
505
545
  Inquirex.define id: "tax-intake-2025" do
@@ -554,31 +594,14 @@ end
554
594
 
555
595
  Key semantics:
556
596
 
557
- - **Templating is `{{field}}` interpolation only** — dot-notation keys resolved
558
- against `Answers#to_flat_h`, deliberately inert (no code execution), so
559
- definitions stored in a database render safely. Values interpolated into
560
- `html:` bodies are HTML-escaped automatically; `text:` bodies stay verbatim.
561
- The built-in `{{answers_summary}}` expands to all collected answers.
562
- - **`if:` gates** reuse the serializable rule AST (`not_empty(:email)`, ...).
563
- A false rule records `:skipped` — declare no actions (or gate them) when a
564
- flow should only save answers.
565
- - **`run { |answers, outbox| ... }`** is the full-Ruby escape hatch; like all
566
- lambdas it is stripped from JSON.
567
- - **Failures are isolated**: a raising effect records `:failed` in
568
- `outbox.results` and never blocks other actions.
597
+ - **Templating is `{{field}}` interpolation only** — dot-notation keys resolved against `Answers#to_flat_h`, deliberately inert (no code execution), so definitions stored in a database render safely. Values interpolated into `html:` bodies are HTML-escaped automatically; `text:` bodies stay verbatim. The built-in `{{answers_summary}}` expands to all collected answers.
598
+ - **`if:` gates** reuse the serializable rule AST (`not_empty(:email)`, ...). A false rule records `:skipped` — declare no actions (or gate them) when a flow should only save answers.
599
+ - **`run { |answers, outbox| ... }`** is the full-Ruby escape hatch; like all lambdas it is stripped from JSON.
600
+ - **Failures are isolated**: a raising effect records `:failed` in `outbox.results` and never blocks other actions.
569
601
  - **Images** in HTML bodies must be external URLs; attachments are unsupported.
570
- - The `mail` gem is a soft dependency, needed only when a message is built
571
- (Rails hosts already have it via ActionMailer).
572
- - **`webhook url:`** POSTs `{"answers": {...}}` as JSON to a static URL. The
573
- URL's host must be covered by `allowed_domains`, declared at the top of the
574
- definition so the flow's egress surface is auditable at a glance. The check
575
- runs inside `Definition.new` — a JSON definition whose webhook URL was
576
- tampered with fails at *rehydration*, before anything executes. Also
577
- enforced: https only (plain http just for localhost), no userinfo, no
578
- `{{field}}` templates in URLs (the destination must be static), redirects
579
- are not followed, and non-2xx responses record `:failed`.
580
- - Effects are extensible: `Inquirex::Actions.register(:save_record, MyEffect)`
581
- gives a new verb both DSL and JSON wire support.
602
+ - The `mail` gem is a soft dependency, needed only when a message is built (Rails hosts already have it via ActionMailer).
603
+ - **`webhook url:`** POSTs `{"answers": {...}}` as JSON to a static URL. The URL's host must be covered by `allowed_domains`, declared at the top of the definition so the flow's egress surface is auditable at a glance. The check runs inside `Definition.new` — a JSON definition whose webhook URL was tampered with fails at *rehydration*, before anything executes. Also enforced: https only (plain http just for localhost), no userinfo, no `{{field}}` templates in URLs (the destination must be static), redirects are not followed, and non-2xx responses record `:failed`.
604
+ - Effects are extensible: `Inquirex::Actions.register(:save_record, MyEffect)` gives a new verb both DSL and JSON wire support.
582
605
 
583
606
  ## Serialization
584
607
 
@@ -597,17 +620,15 @@ Serialized structure includes:
597
620
  - Steps and transitions
598
621
  - Rule AST payloads
599
622
  - Widget hints
600
- - Post-completion actions (`actions`) with their rules and effects; `run`
601
- blocks are stripped, and an action left with no serializable effects is
602
- omitted entirely
603
- - The `allowed_domains` egress allowlist, re-enforced against webhook URLs
604
- every time a definition is rehydrated
623
+ - Post-completion actions (`actions`) with their rules and effects; `run` blocks are stripped, and an action left with no serializable effects is omitted entirely
624
+ - The `allowed_domains` egress allowlist, re-enforced against webhook URLs every time a definition is rehydrated
605
625
 
606
626
  Important serialization details:
607
627
 
608
628
  - Rule objects and accumulator shapes serialize and deserialize cleanly
609
629
  - Proc/lambda defaults are stripped from JSON
610
630
  - `requires_server: true` transition flag is preserved
631
+ - `required: false` step flag is preserved (omitted when true, the default)
611
632
  - Snake-case theme keys are converted to camelCase on serialization to match the JS widget contract
612
633
 
613
634
  ## Answers Wrapper
@@ -652,6 +673,7 @@ Common exceptions under `Inquirex::Errors`:
652
673
  - `AlreadyFinishedError`
653
674
  - `ValidationError`
654
675
  - `NonCollectingStepError`
676
+ - `RequiredStepError`
655
677
 
656
678
  ## Development
657
679
 
data/lib/.DS_Store ADDED
Binary file
@@ -17,6 +17,7 @@ module Inquirex
17
17
  @transitions = []
18
18
  @skip_if = nil
19
19
  @default = nil
20
+ @required = true
20
21
  @compute = nil
21
22
  @widget_hints = {}
22
23
  @accumulations = []
@@ -114,12 +115,33 @@ module Inquirex
114
115
 
115
116
  # Sets a default value for this step (shown pre-filled; user can change it).
116
117
  # Can be a static value or a proc receiving collected answers so far.
118
+ # On a `required false` step the default is also what Engine#skip records
119
+ # into the answers when the user skips the question.
117
120
  #
118
121
  # @param value [Object, Proc]
119
122
  def default(value = nil, &block)
120
123
  @default = block || value
121
124
  end
122
125
 
126
+ # Declares whether the user must answer this step (true by default).
127
+ # `required false` marks the question as optional: renderers show a small
128
+ # Skip control, and Engine#skip records the step's default (when one is
129
+ # declared) into the answers while marking the step as skipped.
130
+ #
131
+ # @example An optional question with a skip default
132
+ # ask :dependents do
133
+ # type :integer
134
+ # question "How many dependents?"
135
+ # required false
136
+ # default 0
137
+ # transition to: :done
138
+ # end
139
+ #
140
+ # @param value [Boolean] false to make the step skippable
141
+ def required(value = true)
142
+ @required = value
143
+ end
144
+
123
145
  # Registers a compute block: auto-calculates a value from answers, not shown to user.
124
146
  # The computed value is stored server-side only and stripped from JSON serialization.
125
147
  #
@@ -143,6 +165,7 @@ module Inquirex
143
165
  transitions: @transitions,
144
166
  skip_if: @skip_if,
145
167
  default: @default,
168
+ required: @required,
146
169
  widget_hints: resolve_widget_hints,
147
170
  accumulations: @accumulations
148
171
  )
@@ -11,6 +11,8 @@ module Inquirex
11
11
  history: ->(v) { Array(v).map { |e| e&.to_sym } },
12
12
  answers: ->(v) { symbolize_answers(v) },
13
13
  totals: ->(v) { symbolize_answers(v) },
14
+ suggestions: ->(v) { symbolize_answers(v) },
15
+ skipped: ->(v) { Array(v).map { |e| e&.to_sym } },
14
16
  completion_metadata: ->(v) { symbolize_answers(v) }
15
17
  }.freeze
16
18
 
@@ -6,6 +6,7 @@ module Inquirex
6
6
  #
7
7
  # Collecting steps (ask, confirm): call engine.answer(value)
8
8
  # Display steps (say, header, btw, warning): call engine.advance
9
+ # Optional steps (declared `required false`): engine.skip is also allowed
9
10
  #
10
11
  # Validates each answer via an optional Validation::Adapter, then advances using
11
12
  # node transitions. Skips steps whose skip_if rule evaluates to true.
@@ -21,6 +22,24 @@ module Inquirex
21
22
  # @return [CompletionMetadata, nil] nil until the flow finishes
22
23
  attr_accessor :completion_metadata
23
24
 
25
+ # Answer suggestions produced by prefill! for multi-select steps, keyed by
26
+ # step id. A suggestion pre-populates the step's choices in a renderer but
27
+ # — unlike an answer — never satisfies skip_if rules: multi-select
28
+ # extraction is treated as a hint the user confirms and may extend, not a
29
+ # deterministic fact. Cleared per step once the user answers it.
30
+ #
31
+ # @return [Hash{Symbol => Array}]
32
+ attr_reader :suggestions
33
+
34
+ # Step ids the user explicitly skipped via #skip, in the order they were
35
+ # skipped. Distinguishes default-by-skip values in answers from values the
36
+ # user actually provided. Steps elided automatically by their skip_if rule
37
+ # are NOT listed here — they were never presented, so the user cannot have
38
+ # declined them.
39
+ #
40
+ # @return [Array<Symbol>]
41
+ attr_reader :skipped
42
+
24
43
  # Exceptions raised by after_completion hooks, in the order they were
25
44
  # raised. Hooks are isolated from one another, so a raising hook is
26
45
  # recorded here rather than propagated — callers that care can inspect
@@ -41,6 +60,8 @@ module Inquirex
41
60
  @completion_metadata = nil
42
61
  @after_completion_hooks = []
43
62
  @completion_hook_errors = []
63
+ @suggestions = {}
64
+ @skipped = []
44
65
  @history << @current_step_id
45
66
  skip_display_steps_if_needed
46
67
  end
@@ -81,6 +102,7 @@ module Inquirex
81
102
  raise Errors::ValidationError, "Validation failed: #{result.errors.join(", ")}" unless result.valid?
82
103
 
83
104
  @answers[@current_step_id] = value
105
+ @suggestions.delete(@current_step_id)
84
106
  apply_accumulations(current_step, value)
85
107
  advance_step
86
108
  end
@@ -94,6 +116,53 @@ module Inquirex
94
116
  advance_step
95
117
  end
96
118
 
119
+ # Skips the current optional collecting step at the user's request — the
120
+ # engine-side handler for a widget's Skip button. Only steps declared with
121
+ # `required false` may be skipped.
122
+ #
123
+ # When the step has a default, the default is recorded into the answers and
124
+ # contributes to accumulators exactly as if the user had submitted it; the
125
+ # step id lands in #skipped so consumers can tell the value apart from one
126
+ # the user actually provided. Without a default, no answers entry is
127
+ # written (rules read a missing key as nil, so branching is unaffected).
128
+ # Advances through transitions exactly like #answer.
129
+ #
130
+ # @example Optional question, skipped by the user
131
+ # engine.current_step.required? # => false
132
+ # engine.skip
133
+ # engine.answers[:dependents] # => 0 (the step's default)
134
+ # engine.skipped # => [:dependents]
135
+ #
136
+ # @return [void]
137
+ # @raise [Errors::AlreadyFinishedError] if the flow has already finished
138
+ # @raise [Errors::NonCollectingStepError] if the current step is a display verb
139
+ # @raise [Errors::RequiredStepError] if the current step is required
140
+ def skip
141
+ raise Errors::AlreadyFinishedError, "Flow is already finished" if finished?
142
+ raise Errors::NonCollectingStepError, "Step #{@current_step_id} is a display step; use #advance instead" \
143
+ unless current_step.collecting?
144
+ raise Errors::RequiredStepError, "Step #{@current_step_id} is required and cannot be skipped" \
145
+ if current_step.required?
146
+
147
+ node = current_step
148
+ default = resolve_default(node)
149
+ unless default.nil?
150
+ @answers[@current_step_id] = default
151
+ apply_accumulations(node, default)
152
+ end
153
+ @skipped << @current_step_id unless @skipped.include?(@current_step_id)
154
+ @suggestions.delete(@current_step_id)
155
+ advance_step
156
+ end
157
+
158
+ # Whether the user explicitly skipped the given step via #skip.
159
+ #
160
+ # @param step_id [Symbol, String] step id
161
+ # @return [Boolean]
162
+ def skipped?(step_id)
163
+ @skipped.include?(step_id.to_sym)
164
+ end
165
+
97
166
  # Merges a hash of { step_id => value } into the top-level answers without
98
167
  # clobbering answers the user has already provided. Used by LLM clarify
99
168
  # steps to populate downstream answers from free-text extraction so that
@@ -115,12 +184,28 @@ module Inquirex
115
184
  next if value.respond_to?(:empty?) && value.empty?
116
185
 
117
186
  sym = key.to_sym
118
- @answers[sym] = value unless @answers.key?(sym)
187
+ if multi_select_step?(sym)
188
+ # Multi-select extraction is a hint, not a fact: the user may have
189
+ # more selections in mind than the text revealed. Record it as a
190
+ # suggestion so renderers pre-check the choices while the question
191
+ # is still asked; skip_if rules see no answer and do not fire.
192
+ @suggestions[sym] = Array(value) unless @answers.key?(sym)
193
+ else
194
+ @answers[sym] = value unless @answers.key?(sym)
195
+ end
119
196
  end
120
197
  skip_if_needed unless finished?
121
198
  @answers
122
199
  end
123
200
 
201
+ # The prefill suggestion for a step, or nil when none was recorded.
202
+ #
203
+ # @param step_id [Symbol, String] step id
204
+ # @return [Array, nil] suggested selections for a multi-select step
205
+ def suggestion_for(step_id)
206
+ @suggestions[step_id.to_sym]
207
+ end
208
+
124
209
  # Registers a hook to run when the flow finishes. The block receives the
125
210
  # engine; front-ends typically use it to attach a rich
126
211
  # completion_metadata (host, user, ips, terminal, ...). Optional — after
@@ -163,18 +248,24 @@ module Inquirex
163
248
  answers: @answers,
164
249
  history: @history,
165
250
  totals: @totals,
251
+ suggestions: @suggestions,
252
+ skipped: @skipped,
166
253
  completion_metadata: @completion_metadata&.to_h
167
254
  }
168
255
  end
169
256
 
170
257
  # The collected answers with the completion metadata (when a renderer
171
- # attached one) merged in under the :completion_metadata key.
258
+ # attached one) merged in under the :completion_metadata key, and the
259
+ # user-skipped step ids (when any) under the :skipped key — so
260
+ # post-completion actions and API consumers can tell default-by-skip
261
+ # values apart from answers the user actually provided.
172
262
  #
173
263
  # @return [Hash]
174
264
  def answers_with_metadata
175
- return @answers if @completion_metadata.nil?
176
-
177
- @answers.merge(completion_metadata: @completion_metadata.to_h)
265
+ extra = {}
266
+ extra[:completion_metadata] = @completion_metadata.to_h if @completion_metadata
267
+ extra[:skipped] = @skipped.dup unless @skipped.empty?
268
+ extra.empty? ? @answers : @answers.merge(extra)
178
269
  end
179
270
 
180
271
  # Rebuilds an Engine from a previously saved state.
@@ -202,6 +293,17 @@ module Inquirex
202
293
  @completion_metadata = CompletionMetadata.from_h(state[:completion_metadata])
203
294
  @after_completion_hooks = []
204
295
  @completion_hook_errors = []
296
+ @suggestions = state[:suggestions] || {}
297
+ @skipped = state[:skipped] || []
298
+ end
299
+
300
+ # Whether +step_id+ names a multi-select step in the definition. Unknown
301
+ # ids (e.g. an extract schema field that matches no step) are not.
302
+ #
303
+ # @param step_id [Symbol]
304
+ # @return [Boolean]
305
+ def multi_select_step?(step_id)
306
+ @definition.step_ids.include?(step_id) && @definition.step(step_id).type == :multi_enum
205
307
  end
206
308
 
207
309
  def init_totals
@@ -217,6 +319,19 @@ module Inquirex
217
319
  end
218
320
  end
219
321
 
322
+ # The step's default as a concrete value: a Proc default (server-side only,
323
+ # stripped from JSON) is called with the answers collected so far, exactly
324
+ # as a renderer pre-filling the field would resolve it.
325
+ #
326
+ # @param node [Node]
327
+ # @return [Object, nil]
328
+ def resolve_default(node)
329
+ default = node.default
330
+ return default unless default.is_a?(Proc)
331
+
332
+ default.arity.zero? ? default.call : default.call(@answers)
333
+ end
334
+
220
335
  def advance_step
221
336
  node = @definition.step(@current_step_id)
222
337
  next_id = node.next_step_id(@answers)
@@ -25,6 +25,10 @@ module Inquirex
25
25
  # Use Engine#advance for non-collecting steps.
26
26
  class NonCollectingStepError < EngineError; end
27
27
 
28
+ # Raised when Engine#skip is called on a step that is required (the default).
29
+ # Only steps declared with `required false` may be skipped by the user.
30
+ class RequiredStepError < EngineError; end
31
+
28
32
  # Raised when serializing or deserializing a Definition to/from JSON fails.
29
33
  class SerializationError < Error; end
30
34
 
data/lib/inquirex/node.rb CHANGED
@@ -23,6 +23,8 @@ module Inquirex
23
23
  # @attr_reader transitions [Array<Transition>] ordered conditional next-step edges
24
24
  # @attr_reader skip_if [Rules::Base, nil] rule to skip this step entirely
25
25
  # @attr_reader default [Object, nil] default value (pre-fill, user can change)
26
+ # @attr_reader required [Boolean] whether the user must answer (true by default);
27
+ # `required false` steps render a Skip control and accept Engine#skip
26
28
  # @attr_reader widget_hints [Hash{Symbol => WidgetHint}, nil] rendering hints per target
27
29
  class Node
28
30
  # Valid DSL verbs and which ones collect input from the user.
@@ -48,6 +50,7 @@ module Inquirex
48
50
  :transitions,
49
51
  :skip_if,
50
52
  :default,
53
+ :required,
51
54
  :widget_hints,
52
55
  :accumulations
53
56
 
@@ -60,6 +63,7 @@ module Inquirex
60
63
  transitions: [],
61
64
  skip_if: nil,
62
65
  default: nil,
66
+ required: true,
63
67
  widget_hints: nil,
64
68
  accumulations: [])
65
69
  @id = id.to_sym
@@ -70,6 +74,7 @@ module Inquirex
70
74
  @transitions = transitions.freeze
71
75
  @skip_if = skip_if
72
76
  @default = default
77
+ @required = required ? true : false
73
78
  @widget_hints = widget_hints&.freeze
74
79
  @accumulations = accumulations.freeze
75
80
  extract_options(options)
@@ -86,6 +91,15 @@ module Inquirex
86
91
  DISPLAY_VERBS.include?(@verb)
87
92
  end
88
93
 
94
+ # Whether the user must answer this step. True by default; steps declared
95
+ # with `required false` render a Skip control and accept Engine#skip.
96
+ # Only meaningful for collecting steps.
97
+ #
98
+ # @return [Boolean]
99
+ def required?
100
+ @required
101
+ end
102
+
89
103
  # Returns the explicit widget hint for the given target, or nil.
90
104
  #
91
105
  # @param target [Symbol] e.g. :desktop, :mobile, :tty
@@ -135,6 +149,7 @@ module Inquirex
135
149
  hash["options"] = serialize_options if @options
136
150
  hash["skip_if"] = @skip_if.to_h if @skip_if
137
151
  hash["default"] = @default unless @default.nil? || @default.is_a?(Proc)
152
+ hash["required"] = false unless @required
138
153
  elsif @text
139
154
  hash["text"] = @text
140
155
  end
@@ -169,6 +184,8 @@ module Inquirex
169
184
  transitions_data = hash["transitions"] || hash[:transitions] || []
170
185
  skip_if_data = hash["skip_if"] || hash[:skip_if]
171
186
  default = hash["default"] || hash[:default]
187
+ # Fetch chain (not ||) so an explicit false survives; absent key means required.
188
+ required = hash.fetch("required") { hash.fetch(:required, true) }
172
189
  widget_data = hash["widget"] || hash[:widget]
173
190
  accumulate_data = hash["accumulate"] || hash[:accumulate]
174
191
 
@@ -188,6 +205,7 @@ module Inquirex
188
205
  transitions:,
189
206
  skip_if:,
190
207
  default:,
208
+ required:,
191
209
  widget_hints:,
192
210
  accumulations:
193
211
  )
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Inquirex
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: inquirex
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Konstantin Gredeskoul
@@ -36,23 +36,10 @@ executables: []
36
36
  extensions: []
37
37
  extra_rdoc_files: []
38
38
  files:
39
- - ".relaxed_rubocop.yml"
40
- - ".rub"
41
- - ".ruby-version"
42
- - ".secrets.baseline"
43
- - Brewfile
44
39
  - CHANGELOG.md
45
40
  - LICENSE.txt
46
41
  - README.md
47
- - Rakefile
48
- - docs/badges/coverage_badge.svg
49
- - examples/01_readme_example.rb
50
- - examples/02_readme_mermaid.png
51
- - examples/02_readme_mermaid.rb
52
- - examples/03_send_email_actions.rb
53
- - examples/README.md
54
- - justfile
55
- - lefthook.yml
42
+ - lib/.DS_Store
56
43
  - lib/inquirex.rb
57
44
  - lib/inquirex/accumulator.rb
58
45
  - lib/inquirex/actions.rb
@@ -92,7 +79,6 @@ files:
92
79
  - lib/inquirex/version.rb
93
80
  - lib/inquirex/widget_hint.rb
94
81
  - lib/inquirex/widget_registry.rb
95
- - sig/inquirex.rbs
96
82
  homepage: https://github.com/inquirex/inquirex
97
83
  licenses:
98
84
  - MIT