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 +4 -4
- data/CHANGELOG.md +12 -11
- data/README.md +73 -51
- data/lib/.DS_Store +0 -0
- data/lib/inquirex/dsl/step_builder.rb +23 -0
- data/lib/inquirex/engine/state_serializer.rb +2 -0
- data/lib/inquirex/engine.rb +120 -5
- data/lib/inquirex/errors.rb +4 -0
- data/lib/inquirex/node.rb +18 -0
- data/lib/inquirex/version.rb +1 -1
- metadata +2 -16
- data/.relaxed_rubocop.yml +0 -153
- data/.rub +0 -0
- data/.ruby-version +0 -1
- data/.secrets.baseline +0 -127
- data/Brewfile +0 -7
- data/Rakefile +0 -36
- data/docs/badges/coverage_badge.svg +0 -21
- data/examples/01_readme_example.rb +0 -55
- data/examples/02_readme_mermaid.png +0 -0
- data/examples/02_readme_mermaid.rb +0 -36
- data/examples/03_send_email_actions.rb +0 -97
- data/examples/README.md +0 -33
- data/justfile +0 -70
- data/lefthook.yml +0 -35
- data/sig/inquirex.rbs +0 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 67e5f1389368dbb7416aa60a83a7cf613997e72d728d4fb10bd8290741c83901
|
|
4
|
+
data.tar.gz: 105ed41192f4208d7a856e2c12edae0403343f19af45faeae7d557e5bd7048ef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
572
|
-
-
|
|
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
|
-
|
|
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
|
|
data/lib/inquirex/engine.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
@
|
|
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)
|
data/lib/inquirex/errors.rb
CHANGED
|
@@ -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
|
)
|
data/lib/inquirex/version.rb
CHANGED
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.
|
|
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
|
-
-
|
|
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
|