flowengine 0.1.1 → 0.1.2
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/README.md +4 -2
- data/Rakefile +1 -1
- data/justfile +5 -0
- data/lib/flowengine/definition.rb +13 -0
- data/lib/flowengine/dsl/flow_builder.rb +13 -0
- data/lib/flowengine/dsl/rule_helpers.rb +22 -0
- data/lib/flowengine/dsl/step_builder.rb +23 -0
- data/lib/flowengine/dsl.rb +2 -0
- data/lib/flowengine/engine.rb +31 -0
- data/lib/flowengine/errors.rb +11 -0
- data/lib/flowengine/evaluator.rb +9 -0
- data/lib/flowengine/graph/mermaid_exporter.rb +7 -0
- data/lib/flowengine/node.rb +26 -0
- data/lib/flowengine/rules/all.rb +7 -0
- data/lib/flowengine/rules/any.rb +7 -0
- data/lib/flowengine/rules/base.rb +9 -0
- data/lib/flowengine/rules/contains.rb +10 -0
- data/lib/flowengine/rules/equals.rb +9 -0
- data/lib/flowengine/rules/greater_than.rb +9 -0
- data/lib/flowengine/rules/less_than.rb +9 -0
- data/lib/flowengine/rules/not_empty.rb +7 -0
- data/lib/flowengine/transition.rb +14 -0
- data/lib/flowengine/validation/adapter.rb +13 -0
- data/lib/flowengine/validation/null_adapter.rb +4 -0
- data/lib/flowengine/version.rb +2 -1
- data/lib/flowengine.rb +33 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 758428572c105952cb5cf538db6ff0307b60c2a5cbf6187344664d2d063d680a
|
|
4
|
+
data.tar.gz: e4b3db255b71a7faa3f54f024d96440dbf62aaa383d4a01d96ac5bff58517bcb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6ebe4ebeaf8f47846fe4e831457de894a43e0e544c0f99453ecb9f6d1d535edcef2f8e5f83fc866984e731c865d1e0b6b8ecc3aa46e64b220fb44942441f3491
|
|
7
|
+
data.tar.gz: 6447ed169da61924456f6f7007b6373ad8f42cc03a667aa3fff7a39198a42b458811e477401c2df385a588466cb85b783ffc8cbf6e187d6a8b2cd8b81cc28112
|
data/README.md
CHANGED
|
@@ -8,10 +8,12 @@ This gem does not have any UI or an interactive component. It is used as the fou
|
|
|
8
8
|
|
|
9
9
|
The simplest way to see this in action is to use the companion gem [`flowengine-cli`](https://rubygems.org/gems/flowengine-cli), which, given the flow DSL will walk the user through the questioniare according to the DSL flow definition, but using terminal UI and ASCII-based flow.
|
|
10
10
|
|
|
11
|
-
A declarative flow engine for building rules-driven wizards and intake forms in pure Ruby
|
|
11
|
+
**A slightly different explanation is that it offere a declarative flow engine for building rules-driven wizards and intake forms in pure Ruby.**
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
> [!NOTE]
|
|
14
|
+
> FlowEngine lets you define multi-step flows as **directed graphs** with **conditional branching**, evaluate transitions using an **AST-based rule system**, and collect structured answers through a **stateful runtime engine** — all without framework dependencies.
|
|
14
15
|
|
|
16
|
+
> [!CAUTION]
|
|
15
17
|
> **This is not a form builder.** It's a *Form Definition Engine* that separates flow logic, data schema, and UI rendering into independent concerns.
|
|
16
18
|
|
|
17
19
|
## Installation
|
data/Rakefile
CHANGED
|
@@ -26,7 +26,7 @@ end
|
|
|
26
26
|
task build: :permissions
|
|
27
27
|
|
|
28
28
|
YARD::Rake::YardocTask.new(:doc) do |t|
|
|
29
|
-
t.files = %w[lib/**/*.rb exe/*.rb - README.md LICENSE.txt]
|
|
29
|
+
t.files = %w[lib/**/*.rb exe/*.rb - README.md LICENSE.txt CHANGELOG.md]
|
|
30
30
|
t.options.unshift("--title", '"FlowEngine — DSL + AST for buildiong complex flows in Ruby."')
|
|
31
31
|
t.after = -> { exec("open doc/index.html") } if RUBY_PLATFORM =~ /darwin/
|
|
32
32
|
end
|
data/justfile
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
|
+
# Immutable, versionable flow graph: maps step ids to {Node} objects and defines the entry point.
|
|
5
|
+
# Built by {DSL::FlowBuilder}; consumed by {Engine} for navigation.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader start_step_id [Symbol] id of the first step in the flow
|
|
8
|
+
# @attr_reader steps [Hash<Symbol, Node>] frozen map of step id => node (read-only)
|
|
4
9
|
class Definition
|
|
5
10
|
attr_reader :start_step_id, :steps
|
|
6
11
|
|
|
12
|
+
# @param start_step_id [Symbol] id of the initial step
|
|
13
|
+
# @param nodes [Hash<Symbol, Node>] all steps keyed by id
|
|
14
|
+
# @raise [DefinitionError] if start_step_id is not present in nodes
|
|
7
15
|
def initialize(start_step_id:, nodes:)
|
|
8
16
|
@start_step_id = start_step_id
|
|
9
17
|
@steps = nodes.freeze
|
|
@@ -11,14 +19,19 @@ module FlowEngine
|
|
|
11
19
|
freeze
|
|
12
20
|
end
|
|
13
21
|
|
|
22
|
+
# @return [Node] the node for the start step
|
|
14
23
|
def start_step
|
|
15
24
|
step(start_step_id)
|
|
16
25
|
end
|
|
17
26
|
|
|
27
|
+
# @param id [Symbol] step id
|
|
28
|
+
# @return [Node] the node for that step
|
|
29
|
+
# @raise [UnknownStepError] if id is not in steps
|
|
18
30
|
def step(id)
|
|
19
31
|
steps.fetch(id) { raise UnknownStepError, "Unknown step: #{id.inspect}" }
|
|
20
32
|
end
|
|
21
33
|
|
|
34
|
+
# @return [Array<Symbol>] all step ids in the definition
|
|
22
35
|
def step_ids
|
|
23
36
|
steps.keys
|
|
24
37
|
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module DSL
|
|
5
|
+
# Builds a {Definition} from the declarative DSL used in {FlowEngine.define}.
|
|
6
|
+
# Provides {#start} and {#step}; each step block is evaluated by {StepBuilder} with {RuleHelpers}.
|
|
5
7
|
class FlowBuilder
|
|
6
8
|
include RuleHelpers
|
|
7
9
|
|
|
@@ -10,16 +12,27 @@ module FlowEngine
|
|
|
10
12
|
@nodes = {}
|
|
11
13
|
end
|
|
12
14
|
|
|
15
|
+
# Sets the entry step id for the flow.
|
|
16
|
+
#
|
|
17
|
+
# @param step_id [Symbol] id of the first step
|
|
13
18
|
def start(step_id)
|
|
14
19
|
@start_step_id = step_id
|
|
15
20
|
end
|
|
16
21
|
|
|
22
|
+
# Defines one step by id; the block is evaluated in a {StepBuilder} context.
|
|
23
|
+
#
|
|
24
|
+
# @param id [Symbol] step id
|
|
25
|
+
# @yield block evaluated in {StepBuilder} (type, question, options, transition, etc.)
|
|
17
26
|
def step(id, &)
|
|
18
27
|
builder = StepBuilder.new
|
|
19
28
|
builder.instance_eval(&)
|
|
20
29
|
@nodes[id] = builder.build(id)
|
|
21
30
|
end
|
|
22
31
|
|
|
32
|
+
# Produces the frozen {Definition} from the accumulated start and steps.
|
|
33
|
+
#
|
|
34
|
+
# @return [Definition]
|
|
35
|
+
# @raise [DefinitionError] if start was not set or no steps were defined
|
|
23
36
|
def build
|
|
24
37
|
raise DefinitionError, "No start step defined" if @start_step_id.nil?
|
|
25
38
|
raise DefinitionError, "No steps defined" if @nodes.empty?
|
|
@@ -2,31 +2,53 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module DSL
|
|
5
|
+
# Factory methods for rule objects, included in {FlowBuilder} and {StepBuilder}.
|
|
6
|
+
# Use these inside step blocks for transition conditions and visible_if.
|
|
5
7
|
module RuleHelpers
|
|
8
|
+
# @param field [Symbol] answer key (step id)
|
|
9
|
+
# @param value [Object] value to check for in the array
|
|
10
|
+
# @return [Rules::Contains]
|
|
6
11
|
def contains(field, value)
|
|
7
12
|
Rules::Contains.new(field, value)
|
|
8
13
|
end
|
|
9
14
|
|
|
15
|
+
# @param field [Symbol] answer key
|
|
16
|
+
# @param value [Object] expected value
|
|
17
|
+
# @return [Rules::Equals]
|
|
10
18
|
def equals(field, value)
|
|
11
19
|
Rules::Equals.new(field, value)
|
|
12
20
|
end
|
|
13
21
|
|
|
22
|
+
# @param field [Symbol] answer key (value coerced to integer for comparison)
|
|
23
|
+
# @param value [Integer] threshold
|
|
24
|
+
# @return [Rules::GreaterThan]
|
|
14
25
|
def greater_than(field, value)
|
|
15
26
|
Rules::GreaterThan.new(field, value)
|
|
16
27
|
end
|
|
17
28
|
|
|
29
|
+
# @param field [Symbol] answer key (value coerced to integer for comparison)
|
|
30
|
+
# @param value [Integer] threshold
|
|
31
|
+
# @return [Rules::LessThan]
|
|
18
32
|
def less_than(field, value)
|
|
19
33
|
Rules::LessThan.new(field, value)
|
|
20
34
|
end
|
|
21
35
|
|
|
36
|
+
# @param field [Symbol] answer key
|
|
37
|
+
# @return [Rules::NotEmpty]
|
|
22
38
|
def not_empty(field)
|
|
23
39
|
Rules::NotEmpty.new(field)
|
|
24
40
|
end
|
|
25
41
|
|
|
42
|
+
# Logical AND of multiple rules.
|
|
43
|
+
# @param rules [Array<Rules::Base>] rules to combine
|
|
44
|
+
# @return [Rules::All]
|
|
26
45
|
def all(*rules)
|
|
27
46
|
Rules::All.new(*rules)
|
|
28
47
|
end
|
|
29
48
|
|
|
49
|
+
# Logical OR of multiple rules.
|
|
50
|
+
# @param rules [Array<Rules::Base>] rules to combine
|
|
51
|
+
# @return [Rules::Any]
|
|
30
52
|
def any(*rules)
|
|
31
53
|
Rules::Any.new(*rules)
|
|
32
54
|
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module DSL
|
|
5
|
+
# Builds a single {Node} from step DSL (type, question, options, transitions, visibility).
|
|
6
|
+
# Used by {FlowBuilder#step}; includes {RuleHelpers} for transition/visibility conditions.
|
|
5
7
|
class StepBuilder
|
|
6
8
|
include RuleHelpers
|
|
7
9
|
|
|
@@ -15,34 +17,55 @@ module FlowEngine
|
|
|
15
17
|
@decorations = nil
|
|
16
18
|
end
|
|
17
19
|
|
|
20
|
+
# Sets the step/input type (e.g. :multi_select, :number_matrix).
|
|
21
|
+
# @param value [Symbol]
|
|
18
22
|
def type(value)
|
|
19
23
|
@type = value
|
|
20
24
|
end
|
|
21
25
|
|
|
26
|
+
# Sets the prompt/label for the step.
|
|
27
|
+
# @param text [String]
|
|
22
28
|
def question(text)
|
|
23
29
|
@question = text
|
|
24
30
|
end
|
|
25
31
|
|
|
32
|
+
# Sets the list of options for multi-select or choice steps.
|
|
33
|
+
# @param list [Array]
|
|
26
34
|
def options(list)
|
|
27
35
|
@options = list
|
|
28
36
|
end
|
|
29
37
|
|
|
38
|
+
# Sets the list of field names for matrix-style steps (e.g. number_matrix).
|
|
39
|
+
# @param list [Array]
|
|
30
40
|
def fields(list)
|
|
31
41
|
@fields = list
|
|
32
42
|
end
|
|
33
43
|
|
|
44
|
+
# Optional UI decorations (opaque to the engine).
|
|
45
|
+
# @param decorations [Object]
|
|
34
46
|
def decorations(decorations)
|
|
35
47
|
@decorations = decorations
|
|
36
48
|
end
|
|
37
49
|
|
|
50
|
+
# Adds a conditional transition to another step. First matching transition wins.
|
|
51
|
+
#
|
|
52
|
+
# @param to [Symbol] target step id
|
|
53
|
+
# @param if_rule [Rules::Base, nil] condition (nil = unconditional)
|
|
38
54
|
def transition(to:, if_rule: nil)
|
|
39
55
|
@transitions << Transition.new(target: to, rule: if_rule)
|
|
40
56
|
end
|
|
41
57
|
|
|
58
|
+
# Sets the visibility rule for this step (DAG mode: step shown only when rule is true).
|
|
59
|
+
#
|
|
60
|
+
# @param rule [Rules::Base]
|
|
42
61
|
def visible_if(rule)
|
|
43
62
|
@visibility_rule = rule
|
|
44
63
|
end
|
|
45
64
|
|
|
65
|
+
# Builds the {Node} for the given step id from accumulated attributes.
|
|
66
|
+
#
|
|
67
|
+
# @param id [Symbol] step id
|
|
68
|
+
# @return [Node]
|
|
46
69
|
def build(id)
|
|
47
70
|
Node.new(
|
|
48
71
|
id: id,
|
data/lib/flowengine/dsl.rb
CHANGED
|
@@ -5,6 +5,8 @@ require_relative "dsl/step_builder"
|
|
|
5
5
|
require_relative "dsl/flow_builder"
|
|
6
6
|
|
|
7
7
|
module FlowEngine
|
|
8
|
+
# Namespace for the declarative flow DSL: {FlowBuilder} builds a {Definition} from blocks,
|
|
9
|
+
# {StepBuilder} builds individual {Node}s, and {RuleHelpers} provide rule factory methods.
|
|
8
10
|
module DSL
|
|
9
11
|
end
|
|
10
12
|
end
|
data/lib/flowengine/engine.rb
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
|
+
# Runtime session that drives flow navigation: holds definition, answers, and current step.
|
|
5
|
+
# Validates each answer via an optional {Validation::Adapter}, then advances using node transitions.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader definition [Definition] immutable flow definition
|
|
8
|
+
# @attr_reader answers [Hash] step_id => value (mutable as user answers)
|
|
9
|
+
# @attr_reader history [Array<Symbol>] ordered list of step ids visited (including current)
|
|
10
|
+
# @attr_reader current_step_id [Symbol, nil] current step id, or nil when flow is finished
|
|
4
11
|
class Engine
|
|
5
12
|
attr_reader :definition, :answers, :history, :current_step_id
|
|
6
13
|
|
|
14
|
+
# @param definition [Definition] the flow to run
|
|
15
|
+
# @param validator [Validation::Adapter] validator for step answers (default: {Validation::NullAdapter})
|
|
7
16
|
def initialize(definition, validator: Validation::NullAdapter.new)
|
|
8
17
|
@definition = definition
|
|
9
18
|
@answers = {}
|
|
@@ -13,16 +22,23 @@ module FlowEngine
|
|
|
13
22
|
@history << @current_step_id
|
|
14
23
|
end
|
|
15
24
|
|
|
25
|
+
# @return [Node, nil] current step node, or nil if flow is finished
|
|
16
26
|
def current_step
|
|
17
27
|
return nil if finished?
|
|
18
28
|
|
|
19
29
|
definition.step(@current_step_id)
|
|
20
30
|
end
|
|
21
31
|
|
|
32
|
+
# @return [Boolean] true when there is no current step (flow ended)
|
|
22
33
|
def finished?
|
|
23
34
|
@current_step_id.nil?
|
|
24
35
|
end
|
|
25
36
|
|
|
37
|
+
# Submits an answer for the current step, validates it, stores it, and advances to the next step.
|
|
38
|
+
#
|
|
39
|
+
# @param value [Object] user's answer for the current step
|
|
40
|
+
# @raise [AlreadyFinishedError] if the flow has already finished
|
|
41
|
+
# @raise [ValidationError] if the validator rejects the value
|
|
26
42
|
def answer(value)
|
|
27
43
|
raise AlreadyFinishedError, "Flow is already finished" if finished?
|
|
28
44
|
|
|
@@ -33,6 +49,9 @@ module FlowEngine
|
|
|
33
49
|
advance_step
|
|
34
50
|
end
|
|
35
51
|
|
|
52
|
+
# Serializable state for persistence or resumption.
|
|
53
|
+
#
|
|
54
|
+
# @return [Hash] current_step_id, answers, and history (string/symbol keys as stored)
|
|
36
55
|
def to_state
|
|
37
56
|
{
|
|
38
57
|
current_step_id: @current_step_id,
|
|
@@ -41,6 +60,12 @@ module FlowEngine
|
|
|
41
60
|
}
|
|
42
61
|
end
|
|
43
62
|
|
|
63
|
+
# Rebuilds an engine from a previously saved state (e.g. from DB or session).
|
|
64
|
+
#
|
|
65
|
+
# @param definition [Definition] same definition used when state was captured
|
|
66
|
+
# @param state_hash [Hash] hash with :current_step_id, :answers, :history (keys may be strings)
|
|
67
|
+
# @param validator [Validation::Adapter] validator to use (default: NullAdapter)
|
|
68
|
+
# @return [Engine] restored engine instance
|
|
44
69
|
def self.from_state(definition, state_hash, validator: Validation::NullAdapter.new)
|
|
45
70
|
state = symbolize_state(state_hash)
|
|
46
71
|
engine = allocate
|
|
@@ -48,6 +73,10 @@ module FlowEngine
|
|
|
48
73
|
engine
|
|
49
74
|
end
|
|
50
75
|
|
|
76
|
+
# Normalizes a state hash so step ids and history entries are symbols; answers keys are symbols.
|
|
77
|
+
#
|
|
78
|
+
# @param hash [Hash] raw state (e.g. from JSON)
|
|
79
|
+
# @return [Hash] symbolized state
|
|
51
80
|
def self.symbolize_state(hash)
|
|
52
81
|
return hash unless hash.is_a?(Hash)
|
|
53
82
|
|
|
@@ -66,6 +95,8 @@ module FlowEngine
|
|
|
66
95
|
end
|
|
67
96
|
end
|
|
68
97
|
|
|
98
|
+
# @param answers [Hash] answers map (keys may be strings)
|
|
99
|
+
# @return [Hash] same map with symbol keys
|
|
69
100
|
def self.symbolize_answers(answers)
|
|
70
101
|
return {} unless answers.is_a?(Hash)
|
|
71
102
|
|
data/lib/flowengine/errors.rb
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
|
+
# Base exception for all flowengine errors.
|
|
4
5
|
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when a flow definition is invalid (e.g. missing start step, unknown step reference).
|
|
5
8
|
class DefinitionError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when navigating to or requesting a step id that does not exist in the definition.
|
|
6
11
|
class UnknownStepError < Error; end
|
|
12
|
+
|
|
13
|
+
# Base exception for runtime engine errors (e.g. validation, already finished).
|
|
7
14
|
class EngineError < Error; end
|
|
15
|
+
|
|
16
|
+
# Raised when {Engine#answer} is called after the flow has already finished.
|
|
8
17
|
class AlreadyFinishedError < EngineError; end
|
|
18
|
+
|
|
19
|
+
# Raised when the validator rejects the user's answer for the current step.
|
|
9
20
|
class ValidationError < EngineError; end
|
|
10
21
|
end
|
data/lib/flowengine/evaluator.rb
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
|
+
# Evaluates rule AST nodes against a given answer context.
|
|
5
|
+
# Used by transitions and visibility checks; rule objects implement {Rules::Base#evaluate}.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader answers [Hash] current answer state (step_id => value)
|
|
4
8
|
class Evaluator
|
|
5
9
|
attr_reader :answers
|
|
6
10
|
|
|
11
|
+
# @param answers [Hash] answer context to evaluate rules against
|
|
7
12
|
def initialize(answers)
|
|
8
13
|
@answers = answers
|
|
9
14
|
end
|
|
10
15
|
|
|
16
|
+
# Evaluates a single rule (or returns true if rule is nil).
|
|
17
|
+
#
|
|
18
|
+
# @param rule [Rules::Base, nil] rule to evaluate
|
|
19
|
+
# @return [Boolean] result of rule#evaluate(answers), or true when rule is nil
|
|
11
20
|
def evaluate(rule)
|
|
12
21
|
return true if rule.nil?
|
|
13
22
|
|
|
@@ -2,15 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module Graph
|
|
5
|
+
# Exports a flow {Definition} to Mermaid flowchart syntax for visualization.
|
|
6
|
+
# Nodes are labeled with truncated question text; edges show condition labels when present.
|
|
5
7
|
class MermaidExporter
|
|
8
|
+
# Maximum characters for node labels in the diagram (longer text is truncated with "...").
|
|
6
9
|
MAX_LABEL_LENGTH = 50
|
|
7
10
|
|
|
8
11
|
attr_reader :definition
|
|
9
12
|
|
|
13
|
+
# @param definition [Definition] flow to export
|
|
10
14
|
def initialize(definition)
|
|
11
15
|
@definition = definition
|
|
12
16
|
end
|
|
13
17
|
|
|
18
|
+
# Generates Mermaid flowchart TD (top-down) source.
|
|
19
|
+
#
|
|
20
|
+
# @return [String] Mermaid diagram source (e.g. for Mermaid.js or docs)
|
|
14
21
|
def export
|
|
15
22
|
lines = ["flowchart TD"]
|
|
16
23
|
|
data/lib/flowengine/node.rb
CHANGED
|
@@ -1,9 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
|
+
# A single step in the flow: question metadata, input config, and conditional transitions.
|
|
5
|
+
# Used by {Engine} to determine the next step and by UI/export to render the step.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader id [Symbol] unique step identifier
|
|
8
|
+
# @attr_reader type [Symbol] input type (e.g. :multi_select, :number_matrix)
|
|
9
|
+
# @attr_reader question [String] prompt text for the step
|
|
10
|
+
# @attr_reader options [Array, nil] choices for multi_select; nil for other types
|
|
11
|
+
# @attr_reader fields [Array, nil] field names for number_matrix etc.; nil otherwise
|
|
12
|
+
# @attr_reader transitions [Array<Transition>] ordered list of conditional next-step rules
|
|
13
|
+
# @attr_reader visibility_rule [Rules::Base, nil] rule controlling whether this node is visible (DAG mode)
|
|
4
14
|
class Node
|
|
5
15
|
attr_reader :id, :type, :question, :options, :fields, :transitions, :visibility_rule
|
|
6
16
|
|
|
17
|
+
# @param id [Symbol] step id
|
|
18
|
+
# @param type [Symbol] step/input type
|
|
19
|
+
# @param question [String] label/prompt
|
|
20
|
+
# @param decorations [Object, nil] optional UI decorations (not used by engine)
|
|
21
|
+
# @param options [Array, nil] option list for multi_select
|
|
22
|
+
# @param fields [Array, nil] field list for matrix-style steps
|
|
23
|
+
# @param transitions [Array<Transition>] conditional next-step transitions (default: [])
|
|
24
|
+
# @param visibility_rule [Rules::Base, nil] optional rule for visibility (default: always visible)
|
|
7
25
|
def initialize(id:, # rubocop:disable Metrics/ParameterLists
|
|
8
26
|
type:,
|
|
9
27
|
question:,
|
|
@@ -23,11 +41,19 @@ module FlowEngine
|
|
|
23
41
|
freeze
|
|
24
42
|
end
|
|
25
43
|
|
|
44
|
+
# Resolves the next step id from current answers by evaluating transitions in order.
|
|
45
|
+
#
|
|
46
|
+
# @param answers [Hash] current answer state (step_id => value)
|
|
47
|
+
# @return [Symbol, nil] id of the next step, or nil if no transition matches (flow end)
|
|
26
48
|
def next_step_id(answers)
|
|
27
49
|
match = transitions.find { |t| t.applies?(answers) }
|
|
28
50
|
match&.target
|
|
29
51
|
end
|
|
30
52
|
|
|
53
|
+
# Whether this node should be considered visible given current answers (for DAG/visibility).
|
|
54
|
+
#
|
|
55
|
+
# @param answers [Hash] current answer state
|
|
56
|
+
# @return [Boolean] true if no visibility_rule, else result of rule evaluation
|
|
31
57
|
def visible?(answers)
|
|
32
58
|
return true if visibility_rule.nil?
|
|
33
59
|
|
data/lib/flowengine/rules/all.rb
CHANGED
|
@@ -2,19 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module Rules
|
|
5
|
+
# Composite rule: logical AND of multiple sub-rules. All must be true.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader rules [Array<Base>] sub-rules to evaluate
|
|
5
8
|
class All < Base
|
|
6
9
|
attr_reader :rules
|
|
7
10
|
|
|
11
|
+
# @param rules [Array<Base>] one or more rules (arrays are flattened)
|
|
8
12
|
def initialize(*rules)
|
|
9
13
|
super()
|
|
10
14
|
@rules = rules.flatten.freeze
|
|
11
15
|
freeze
|
|
12
16
|
end
|
|
13
17
|
|
|
18
|
+
# @param answers [Hash] current answers
|
|
19
|
+
# @return [Boolean] true if every sub-rule evaluates to true
|
|
14
20
|
def evaluate(answers)
|
|
15
21
|
rules.all? { |rule| rule.evaluate(answers) }
|
|
16
22
|
end
|
|
17
23
|
|
|
24
|
+
# @return [String] e.g. "(rule1 AND rule2 AND rule3)"
|
|
18
25
|
def to_s
|
|
19
26
|
"(#{rules.join(" AND ")})"
|
|
20
27
|
end
|
data/lib/flowengine/rules/any.rb
CHANGED
|
@@ -2,19 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module Rules
|
|
5
|
+
# Composite rule: logical OR of multiple sub-rules. At least one must be true.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader rules [Array<Base>] sub-rules to evaluate
|
|
5
8
|
class Any < Base
|
|
6
9
|
attr_reader :rules
|
|
7
10
|
|
|
11
|
+
# @param rules [Array<Base>] one or more rules (arrays are flattened)
|
|
8
12
|
def initialize(*rules)
|
|
9
13
|
super()
|
|
10
14
|
@rules = rules.flatten.freeze
|
|
11
15
|
freeze
|
|
12
16
|
end
|
|
13
17
|
|
|
18
|
+
# @param answers [Hash] current answers
|
|
19
|
+
# @return [Boolean] true if any sub-rule evaluates to true
|
|
14
20
|
def evaluate(answers)
|
|
15
21
|
rules.any? { |rule| rule.evaluate(answers) }
|
|
16
22
|
end
|
|
17
23
|
|
|
24
|
+
# @return [String] e.g. "(rule1 OR rule2 OR rule3)"
|
|
18
25
|
def to_s
|
|
19
26
|
"(#{rules.join(" OR ")})"
|
|
20
27
|
end
|
|
@@ -2,11 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module Rules
|
|
5
|
+
# Abstract base for rule AST nodes. Subclasses implement {#evaluate} and {#to_s}
|
|
6
|
+
# for use in transitions and visibility conditions.
|
|
5
7
|
class Base
|
|
8
|
+
# Evaluates the rule against the current answer context.
|
|
9
|
+
#
|
|
10
|
+
# @param _answers [Hash] step_id => value
|
|
11
|
+
# @return [Boolean]
|
|
6
12
|
def evaluate(_answers)
|
|
7
13
|
raise NotImplementedError, "#{self.class}#evaluate must be implemented"
|
|
8
14
|
end
|
|
9
15
|
|
|
16
|
+
# Human-readable representation (e.g. for graph labels).
|
|
17
|
+
#
|
|
18
|
+
# @return [String]
|
|
10
19
|
def to_s
|
|
11
20
|
raise NotImplementedError, "#{self.class}#to_s must be implemented"
|
|
12
21
|
end
|
|
@@ -2,9 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module Rules
|
|
5
|
+
# Rule: the answer for the given field (as an array) includes the given value.
|
|
6
|
+
# Used for multi-select steps (e.g. "BusinessOwnership in earnings").
|
|
7
|
+
#
|
|
8
|
+
# @attr_reader field [Symbol] answer key (step id)
|
|
9
|
+
# @attr_reader value [Object] value that must be present in the array
|
|
5
10
|
class Contains < Base
|
|
6
11
|
attr_reader :field, :value
|
|
7
12
|
|
|
13
|
+
# @param field [Symbol] answer key
|
|
14
|
+
# @param value [Object] value to check for
|
|
8
15
|
def initialize(field, value)
|
|
9
16
|
super()
|
|
10
17
|
@field = field
|
|
@@ -12,10 +19,13 @@ module FlowEngine
|
|
|
12
19
|
freeze
|
|
13
20
|
end
|
|
14
21
|
|
|
22
|
+
# @param answers [Hash] current answers
|
|
23
|
+
# @return [Boolean] true if answers[field] (as array) includes value
|
|
15
24
|
def evaluate(answers)
|
|
16
25
|
Array(answers[field]).include?(value)
|
|
17
26
|
end
|
|
18
27
|
|
|
28
|
+
# @return [String] e.g. "BusinessOwnership in earnings"
|
|
19
29
|
def to_s
|
|
20
30
|
"#{value} in #{field}"
|
|
21
31
|
end
|
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module Rules
|
|
5
|
+
# Rule: the answer for the given field equals the given value.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader field [Symbol] answer key
|
|
8
|
+
# @attr_reader value [Object] expected value
|
|
5
9
|
class Equals < Base
|
|
6
10
|
attr_reader :field, :value
|
|
7
11
|
|
|
12
|
+
# @param field [Symbol] answer key
|
|
13
|
+
# @param value [Object] expected value
|
|
8
14
|
def initialize(field, value)
|
|
9
15
|
super()
|
|
10
16
|
@field = field
|
|
@@ -12,10 +18,13 @@ module FlowEngine
|
|
|
12
18
|
freeze
|
|
13
19
|
end
|
|
14
20
|
|
|
21
|
+
# @param answers [Hash] current answers
|
|
22
|
+
# @return [Boolean] true if answers[field] == value
|
|
15
23
|
def evaluate(answers)
|
|
16
24
|
answers[field] == value
|
|
17
25
|
end
|
|
18
26
|
|
|
27
|
+
# @return [String] e.g. "marital_status == Married"
|
|
19
28
|
def to_s
|
|
20
29
|
"#{field} == #{value}"
|
|
21
30
|
end
|
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module Rules
|
|
5
|
+
# Rule: the answer for the given field (coerced to integer) is greater than the threshold.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader field [Symbol] answer key
|
|
8
|
+
# @attr_reader value [Integer] threshold
|
|
5
9
|
class GreaterThan < Base
|
|
6
10
|
attr_reader :field, :value
|
|
7
11
|
|
|
12
|
+
# @param field [Symbol] answer key
|
|
13
|
+
# @param value [Integer] threshold
|
|
8
14
|
def initialize(field, value)
|
|
9
15
|
super()
|
|
10
16
|
@field = field
|
|
@@ -12,10 +18,13 @@ module FlowEngine
|
|
|
12
18
|
freeze
|
|
13
19
|
end
|
|
14
20
|
|
|
21
|
+
# @param answers [Hash] current answers (field value is coerced with to_i)
|
|
22
|
+
# @return [Boolean] true if answers[field].to_i > value
|
|
15
23
|
def evaluate(answers)
|
|
16
24
|
answers[field].to_i > value
|
|
17
25
|
end
|
|
18
26
|
|
|
27
|
+
# @return [String] e.g. "business_income > 100000"
|
|
19
28
|
def to_s
|
|
20
29
|
"#{field} > #{value}"
|
|
21
30
|
end
|
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module Rules
|
|
5
|
+
# Rule: the answer for the given field (coerced to integer) is less than the threshold.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader field [Symbol] answer key
|
|
8
|
+
# @attr_reader value [Integer] threshold
|
|
5
9
|
class LessThan < Base
|
|
6
10
|
attr_reader :field, :value
|
|
7
11
|
|
|
12
|
+
# @param field [Symbol] answer key
|
|
13
|
+
# @param value [Integer] threshold
|
|
8
14
|
def initialize(field, value)
|
|
9
15
|
super()
|
|
10
16
|
@field = field
|
|
@@ -12,10 +18,13 @@ module FlowEngine
|
|
|
12
18
|
freeze
|
|
13
19
|
end
|
|
14
20
|
|
|
21
|
+
# @param answers [Hash] current answers (field value is coerced with to_i)
|
|
22
|
+
# @return [Boolean] true if answers[field].to_i < value
|
|
15
23
|
def evaluate(answers)
|
|
16
24
|
answers[field].to_i < value
|
|
17
25
|
end
|
|
18
26
|
|
|
27
|
+
# @return [String] e.g. "age < 18"
|
|
19
28
|
def to_s
|
|
20
29
|
"#{field} < #{value}"
|
|
21
30
|
end
|
|
@@ -2,15 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module Rules
|
|
5
|
+
# Rule: the answer for the given field is present and not empty (nil or empty? => false).
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader field [Symbol] answer key
|
|
5
8
|
class NotEmpty < Base
|
|
6
9
|
attr_reader :field
|
|
7
10
|
|
|
11
|
+
# @param field [Symbol] answer key
|
|
8
12
|
def initialize(field)
|
|
9
13
|
super()
|
|
10
14
|
@field = field
|
|
11
15
|
freeze
|
|
12
16
|
end
|
|
13
17
|
|
|
18
|
+
# @param answers [Hash] current answers
|
|
19
|
+
# @return [Boolean] false if nil or empty, true otherwise
|
|
14
20
|
def evaluate(answers)
|
|
15
21
|
val = answers[field]
|
|
16
22
|
return false if val.nil?
|
|
@@ -19,6 +25,7 @@ module FlowEngine
|
|
|
19
25
|
true
|
|
20
26
|
end
|
|
21
27
|
|
|
28
|
+
# @return [String] e.g. "name is not empty"
|
|
22
29
|
def to_s
|
|
23
30
|
"#{field} is not empty"
|
|
24
31
|
end
|
|
@@ -1,19 +1,33 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
|
+
# A single edge from one step to another, optionally guarded by a rule.
|
|
5
|
+
# Transitions are evaluated in order; the first whose rule is true determines the next step.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader target [Symbol] id of the step to go to when this transition applies
|
|
8
|
+
# @attr_reader rule [Rules::Base, nil] condition; nil means unconditional (always applies)
|
|
4
9
|
class Transition
|
|
5
10
|
attr_reader :target, :rule
|
|
6
11
|
|
|
12
|
+
# @param target [Symbol] next step id
|
|
13
|
+
# @param rule [Rules::Base, nil] optional condition (nil = always)
|
|
7
14
|
def initialize(target:, rule: nil)
|
|
8
15
|
@target = target
|
|
9
16
|
@rule = rule
|
|
10
17
|
freeze
|
|
11
18
|
end
|
|
12
19
|
|
|
20
|
+
# Human-readable label for the condition (e.g. for graph export).
|
|
21
|
+
#
|
|
22
|
+
# @return [String] rule#to_s or "always" when rule is nil
|
|
13
23
|
def condition_label
|
|
14
24
|
rule ? rule.to_s : "always"
|
|
15
25
|
end
|
|
16
26
|
|
|
27
|
+
# Whether this transition should be taken given current answers.
|
|
28
|
+
#
|
|
29
|
+
# @param answers [Hash] current answer state
|
|
30
|
+
# @return [Boolean] true if rule is nil or rule evaluates to true
|
|
17
31
|
def applies?(answers)
|
|
18
32
|
return true if rule.nil?
|
|
19
33
|
|
|
@@ -2,21 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module Validation
|
|
5
|
+
# Result of validating a step answer: either valid or a list of error messages.
|
|
6
|
+
#
|
|
7
|
+
# @attr_reader errors [Array<String>] validation error messages (empty when valid)
|
|
5
8
|
class Result
|
|
6
9
|
attr_reader :errors
|
|
7
10
|
|
|
11
|
+
# @param valid [Boolean] whether the input passed validation
|
|
12
|
+
# @param errors [Array<String>] error messages (default: [])
|
|
8
13
|
def initialize(valid:, errors: [])
|
|
9
14
|
@valid = valid
|
|
10
15
|
@errors = errors.freeze
|
|
11
16
|
freeze
|
|
12
17
|
end
|
|
13
18
|
|
|
19
|
+
# @return [Boolean] true if validation passed
|
|
14
20
|
def valid?
|
|
15
21
|
@valid
|
|
16
22
|
end
|
|
17
23
|
end
|
|
18
24
|
|
|
25
|
+
# Abstract adapter for step-level validation. Implement {#validate} to plug in
|
|
26
|
+
# dry-validation, JSON Schema, or other validators; the engine does not depend on a specific one.
|
|
19
27
|
class Adapter
|
|
28
|
+
# Validates the user's input for the given step.
|
|
29
|
+
#
|
|
30
|
+
# @param _node [Node] the current step (for schema/constraints)
|
|
31
|
+
# @param _input [Object] the value submitted by the user
|
|
32
|
+
# @return [Result] valid: true/false and optional errors list
|
|
20
33
|
def validate(_node, _input)
|
|
21
34
|
raise NotImplementedError, "#{self.class}#validate must be implemented"
|
|
22
35
|
end
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module FlowEngine
|
|
4
4
|
module Validation
|
|
5
|
+
# No-op validator: always accepts any input. Used by default when no validation adapter is given.
|
|
5
6
|
class NullAdapter < Adapter
|
|
7
|
+
# @param _node [Node] ignored
|
|
8
|
+
# @param _input [Object] ignored
|
|
9
|
+
# @return [Result] always valid with no errors
|
|
6
10
|
def validate(_node, _input)
|
|
7
11
|
Result.new(valid: true, errors: [])
|
|
8
12
|
end
|
data/lib/flowengine/version.rb
CHANGED
data/lib/flowengine.rb
CHANGED
|
@@ -20,13 +20,46 @@ require_relative "flowengine/engine"
|
|
|
20
20
|
require_relative "flowengine/dsl"
|
|
21
21
|
require_relative "flowengine/graph/mermaid_exporter"
|
|
22
22
|
|
|
23
|
+
# Declarative flow definition and execution engine for wizards, intake forms, and
|
|
24
|
+
# multi-step decision graphs. Separates flow logic, data schema, and UI rendering.
|
|
25
|
+
#
|
|
26
|
+
# @example Define and run a flow
|
|
27
|
+
# definition = FlowEngine.define do
|
|
28
|
+
# start :earnings
|
|
29
|
+
# step :earnings do
|
|
30
|
+
# type :multi_select
|
|
31
|
+
# question "What are your main earnings?"
|
|
32
|
+
# options %w[W2 1099 BusinessOwnership]
|
|
33
|
+
# transition to: :business_details, if: contains(:earnings, "BusinessOwnership")
|
|
34
|
+
# end
|
|
35
|
+
# step :business_details do
|
|
36
|
+
# type :number_matrix
|
|
37
|
+
# question "How many business types?"
|
|
38
|
+
# fields %w[RealEstate SCorp CCorp]
|
|
39
|
+
# end
|
|
40
|
+
# end
|
|
41
|
+
# engine = FlowEngine::Engine.new(definition)
|
|
42
|
+
# engine.answer(["W2", "BusinessOwnership"])
|
|
43
|
+
# engine.current_step_id # => :business_details
|
|
44
|
+
#
|
|
23
45
|
module FlowEngine
|
|
46
|
+
# Builds an immutable {Definition} from the declarative DSL block.
|
|
47
|
+
#
|
|
48
|
+
# @yield context of {DSL::FlowBuilder} (start, step, and rule helpers)
|
|
49
|
+
# @return [Definition] frozen flow definition with start step and nodes
|
|
50
|
+
# @raise [DefinitionError] if no start step or no steps are defined
|
|
24
51
|
def self.define(&)
|
|
25
52
|
builder = DSL::FlowBuilder.new
|
|
26
53
|
builder.instance_eval(&)
|
|
27
54
|
builder.build
|
|
28
55
|
end
|
|
29
56
|
|
|
57
|
+
# Evaluates a string of DSL code and returns the resulting definition.
|
|
58
|
+
# Intended for loading flow definitions from files or stored text.
|
|
59
|
+
#
|
|
60
|
+
# @param text [String] Ruby source containing FlowEngine.define { ... }
|
|
61
|
+
# @return [Definition] the definition produced by evaluating the DSL
|
|
62
|
+
# @raise [DefinitionError] on syntax or evaluation errors
|
|
30
63
|
def self.load_dsl(text)
|
|
31
64
|
# rubocop:disable Security/Eval
|
|
32
65
|
eval(text, TOPLEVEL_BINDING.dup, "(dsl)", 1)
|