inquirex 0.6.1 → 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: 3422d46566a55c973dc712fb60918cde34029d3dc739e36bb1eb2c40110f1f25
4
- data.tar.gz: eb6e54e12a407b21547ac97ca7153b93af516713c0dbcaa83a2323f909f8f816
3
+ metadata.gz: 67e5f1389368dbb7416aa60a83a7cf613997e72d728d4fb10bd8290741c83901
4
+ data.tar.gz: 105ed41192f4208d7a856e2c12edae0403343f19af45faeae7d557e5bd7048ef
5
5
  SHA512:
6
- metadata.gz: 42b3c45e598ebadec67d48e251605e74022cfe1ba0923c21b5998b0c0c2ef8005f2895e43c501dfd9a8613d05821a95653330c5395ff7755fa1892a74761873c
7
- data.tar.gz: c85a9f013fb304ddfc7cbb9742a24167c7da7cefe18fa83425cfc83f1ac5184eeb7960a9ee3592d95ebd82890f7b9568aaea2b1eb488d8840a17106cbe091f3f
6
+ metadata.gz: 9b2a1379ab0b37450c4621b03068939e63a1559dc5dec0d91de03d3819ba765d93f1c2587313738f5bd93bf0a9bbdfb8de5061f715d563407a49b306d7819d6d
7
+ data.tar.gz: 66313dc2a02d2ee469c8076e752ca73b5cd15b857ae475f91e566eea0f18f1756f8dfdddb87b9e66ce7fbde58a5871fc30fcd56bf30f9d812f3dc3abbfae4883
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
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
data/README.md CHANGED
@@ -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,6 +433,7 @@ 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)
@@ -462,6 +464,61 @@ Pass a custom adapter to the engine:
462
464
  engine = Inquirex::Engine.new(definition, validator: my_validator)
463
465
  ```
464
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
+
465
522
  ## Completion Metadata
466
523
 
467
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:
@@ -571,6 +628,7 @@ Important serialization details:
571
628
  - Rule objects and accumulator shapes serialize and deserialize cleanly
572
629
  - Proc/lambda defaults are stripped from JSON
573
630
  - `requires_server: true` transition flag is preserved
631
+ - `required: false` step flag is preserved (omitted when true, the default)
574
632
  - Snake-case theme keys are converted to camelCase on serialization to match the JS widget contract
575
633
 
576
634
  ## Answers Wrapper
@@ -615,6 +673,7 @@ Common exceptions under `Inquirex::Errors`:
615
673
  - `AlreadyFinishedError`
616
674
  - `ValidationError`
617
675
  - `NonCollectingStepError`
676
+ - `RequiredStepError`
618
677
 
619
678
  ## Development
620
679
 
@@ -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
  )
@@ -12,6 +12,7 @@ module Inquirex
12
12
  answers: ->(v) { symbolize_answers(v) },
13
13
  totals: ->(v) { symbolize_answers(v) },
14
14
  suggestions: ->(v) { symbolize_answers(v) },
15
+ skipped: ->(v) { Array(v).map { |e| e&.to_sym } },
15
16
  completion_metadata: ->(v) { symbolize_answers(v) }
16
17
  }.freeze
17
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.
@@ -30,6 +31,15 @@ module Inquirex
30
31
  # @return [Hash{Symbol => Array}]
31
32
  attr_reader :suggestions
32
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
+
33
43
  # Exceptions raised by after_completion hooks, in the order they were
34
44
  # raised. Hooks are isolated from one another, so a raising hook is
35
45
  # recorded here rather than propagated — callers that care can inspect
@@ -51,6 +61,7 @@ module Inquirex
51
61
  @after_completion_hooks = []
52
62
  @completion_hook_errors = []
53
63
  @suggestions = {}
64
+ @skipped = []
54
65
  @history << @current_step_id
55
66
  skip_display_steps_if_needed
56
67
  end
@@ -105,6 +116,53 @@ module Inquirex
105
116
  advance_step
106
117
  end
107
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
+
108
166
  # Merges a hash of { step_id => value } into the top-level answers without
109
167
  # clobbering answers the user has already provided. Used by LLM clarify
110
168
  # steps to populate downstream answers from free-text extraction so that
@@ -191,18 +249,23 @@ module Inquirex
191
249
  history: @history,
192
250
  totals: @totals,
193
251
  suggestions: @suggestions,
252
+ skipped: @skipped,
194
253
  completion_metadata: @completion_metadata&.to_h
195
254
  }
196
255
  end
197
256
 
198
257
  # The collected answers with the completion metadata (when a renderer
199
- # 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.
200
262
  #
201
263
  # @return [Hash]
202
264
  def answers_with_metadata
203
- return @answers if @completion_metadata.nil?
204
-
205
- @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)
206
269
  end
207
270
 
208
271
  # Rebuilds an Engine from a previously saved state.
@@ -231,6 +294,7 @@ module Inquirex
231
294
  @after_completion_hooks = []
232
295
  @completion_hook_errors = []
233
296
  @suggestions = state[:suggestions] || {}
297
+ @skipped = state[:skipped] || []
234
298
  end
235
299
 
236
300
  # Whether +step_id+ names a multi-select step in the definition. Unknown
@@ -255,6 +319,19 @@ module Inquirex
255
319
  end
256
320
  end
257
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
+
258
335
  def advance_step
259
336
  node = @definition.step(@current_step_id)
260
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.1"
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.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Konstantin Gredeskoul