FlowEngine
A declarative flow engine for building rules-driven wizards and intake forms in pure Ruby.
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.
This is not a form builder. It's a Form Definition Engine that separates flow logic, data schema, and UI rendering into independent concerns.
Installation
Add to your Gemfile:
gem "flowengine"Or install directly:
gem install flowengineQuick Start
require "flowengine"
# 1. Define a flow
definition = FlowEngine.define do
start :name
step :name do
type :text
question "What is your name?"
transition to: :age
end
step :age do
type :number
question "How old are you?"
transition to: :beverage, if_rule: greater_than(:age, 20)
transition to: :thanks
end
step :beverage do
type :single_select
question "Pick a drink."
options %w[Beer Wine Cocktail]
transition to: :thanks
end
step :thanks do
type :text
question "Thank you for your responses!"
end
end
# 2. Run the engine
engine = FlowEngine::Engine.new(definition)
engine.answer("Alice") # :name -> :age
engine.answer(25) # :age -> :beverage (25 > 20)
engine.answer("Wine") # :beverage -> :thanks
engine.answer("ok") # :thanks -> finished
engine.finished? # => true
engine.answers
# => { name: "Alice", age: 25, beverage: "Wine", thanks: "ok" }
engine.history
# => [:name, :age, :beverage, :thanks]If Alice were 18 instead, the engine would skip :beverage entirely:
engine.answer("Alice") # :name -> :age
engine.answer(18) # :age -> :thanks (18 is NOT > 20)
engine.answer("ok") # :thanks -> finished
engine.answers
# => { name: "Alice", age: 18, thanks: "ok" }
engine.history
# => [:name, :age, :thanks]Architecture
+-------------------+
| FlowEngine |
|-------------------|
| DSL |
| Definition |
| Node |
| Transition |
| Rules (AST) |
| Evaluator |
| Engine |
| Validation layer |
| Graph Exporter |
+-------------------+
^
+-------------+-------------+
| |
+------------------------+ +---------------------------+
| flowengine-cli | | flowengine-rails |
|------------------------| |---------------------------|
| TTY UI adapter | | Rails Engine |
| JSON output | | ActiveRecord persistence |
| Mermaid export | | Web views/components |
+------------------------+ +---------------------------+
The core has zero UI logic, zero DB logic, and zero framework dependencies. Adapters translate input/output, persist state, and render UI.
Core Components
| Component | Responsibility |
|---|---|
FlowEngine.define |
DSL entry point; returns a frozen Definition
|
Definition |
Immutable container of the flow graph (nodes + start step) |
Node |
A single step: type, question, options/fields, transitions, visibility |
Transition |
A directed edge with an optional rule condition |
Rules::* |
AST nodes for conditional logic (Contains, Equals, All, etc.) |
Evaluator |
Evaluates rules against the current answer store |
Engine |
Stateful runtime: tracks current step, answers, and history |
Validation::Adapter |
Interface for pluggable validation (dry-validation, JSON Schema, etc.) |
Graph::MermaidExporter |
Exports the flow definition as a Mermaid diagram |
The DSL
Defining a Flow
Every flow starts with FlowEngine.define, which returns a frozen, immutable Definition:
definition = FlowEngine.define do
start :first_step # Required: which node to begin at
step :first_step do
# step configuration...
end
step :second_step do
# step configuration...
end
endStep Configuration
Inside a step block, you have access to:
| Method | Purpose | Example |
|---|---|---|
type |
The input type (for UI adapters) |
:text, :number, :single_select, :multi_select, :number_matrix
|
question |
The prompt shown to the user | "What is your filing status?" |
options |
Available choices (for select types) | %w[W2 1099 Business] |
fields |
Named fields (for matrix types) | %w[RealEstate SCorp LLC] |
transition |
Where to go next (with optional condition) | transition to: :next_step, if_rule: equals(:field, "value") |
visible_if |
Visibility rule (for DAG mode) | visible_if contains(:income, "Rental") |
Step Types
Step types are semantic labels consumed by UI adapters. The engine itself is type-agnostic — it stores whatever value you pass to engine.answer(value). The types communicate intent to renderers:
step :filing_status do
type :single_select # One choice from a list
question "What is your filing status?"
options %w[single married_filing_jointly married_filing_separately head_of_household]
transition to: :dependents
end
step :income_types do
type :multi_select # Multiple choices from a list
question "Select all income types."
options %w[W2 1099 Business Investment Rental]
transition to: :business, if_rule: contains(:income_types, "Business")
transition to: :summary
end
step :dependents do
type :number # A numeric value
question "How many dependents?"
transition to: :income_types
end
step :business_details do
type :number_matrix # Multiple named numeric fields
question "How many of each business type?"
fields %w[RealEstate SCorp CCorp Trust LLC]
transition to: :summary
end
step :notes do
type :text # Free-form text
question "Any additional notes?"
transition to: :summary
endTransitions
Transitions define the edges of the flow graph. They are evaluated in order — the first matching transition wins:
step :income_types do
type :multi_select
question "Select income types."
options %w[W2 1099 Business Investment Rental]
# Conditional transitions (checked in order)
transition to: :business_count, if_rule: contains(:income_types, "Business")
transition to: :investment_details, if_rule: contains(:income_types, "Investment")
transition to: :rental_details, if_rule: contains(:income_types, "Rental")
# Unconditional fallback (always matches)
transition to: :state_filing
endKey behavior: Only the first matching transition fires. If the user selects ["Business", "Investment", "Rental"], the engine goes to :business_count first. The subsequent steps must themselves include transitions to eventually reach :investment_details and :rental_details.
A transition with no if_rule: always matches — use it as a fallback at the end of the list.
Visibility Rules
Nodes can have visibility conditions for DAG-mode rendering, where a UI adapter shows/hides steps dynamically:
step :spouse_income do
type :number
question "What is your spouse's annual income?"
visible_if equals(:filing_status, "married_filing_jointly")
transition to: :deductions
endThe engine exposes this via node.visible?(answers), which returns true when the rule is satisfied (or when no visibility rule is set).
Rule System
Rules are AST objects — not hashes, not strings. They are immutable, composable, and evaluate polymorphically.
Atomic Rules
| Rule | DSL Helper | Evaluates | String Representation |
|---|---|---|---|
Contains |
contains(:field, "val") |
Array(answers[:field]).include?("val") |
val in field |
Equals |
equals(:field, "val") |
answers[:field] == "val" |
field == val |
GreaterThan |
greater_than(:field, 10) |
answers[:field].to_i > 10 |
field > 10 |
LessThan |
less_than(:field, 5) |
answers[:field].to_i < 5 |
field < 5 |
NotEmpty |
not_empty(:field) |
answers[:field] is not nil and not empty |
field is not empty |
Composite Rules
Combine atomic rules with boolean logic:
# AND — all conditions must be true
transition to: :special_review,
if_rule: all(
equals(:filing_status, "married_filing_jointly"),
contains(:income_types, "Business"),
greater_than(:business_count, 2)
)
# OR — at least one condition must be true
transition to: :alt_path,
if_rule: any(
contains(:income_types, "Investment"),
contains(:income_types, "Rental")
)Composites nest arbitrarily:
transition to: :complex_review,
if_rule: all(
equals(:filing_status, "married_filing_jointly"),
any(
greater_than(:business_count, 3),
contains(:income_types, "Rental")
),
not_empty(:dependents)
)How Rules Evaluate
Every rule implements evaluate(answers) where answers is the engine's hash of { step_id => value }:
rule = FlowEngine::Rules::Contains.new(:income_types, "Business")
rule.evaluate({ income_types: ["W2", "Business"] }) # => true
rule.evaluate({ income_types: ["W2"] }) # => false
rule = FlowEngine::Rules::All.new(
FlowEngine::Rules::Equals.new(:status, "married"),
FlowEngine::Rules::GreaterThan.new(:dependents, 0)
)
rule.evaluate({ status: "married", dependents: 2 }) # => true
rule.evaluate({ status: "single", dependents: 2 }) # => falseEngine API
Creating and Running
definition = FlowEngine.define { ... }
engine = FlowEngine::Engine.new(definition)Methods
| Method | Returns | Description |
|---|---|---|
engine.current_step_id |
Symbol or nil
|
The ID of the current step |
engine.current_step |
Node or nil
|
The current Node object |
engine.answer(value) |
nil |
Records the answer and advances |
engine.finished? |
Boolean |
true when there are no more steps |
engine.answers |
Hash |
All collected answers { step_id => value }
|
engine.history |
Array<Symbol> |
Ordered list of visited step IDs |
engine.definition |
Definition |
The immutable flow definition |
Error Handling
# Answering after the flow is complete
engine.answer("extra")
# => raises FlowEngine::AlreadyFinishedError
# Referencing an unknown step in a definition
definition.step(:nonexistent)
# => raises FlowEngine::UnknownStepError
# Invalid definition (start step doesn't exist)
FlowEngine.define do
start :missing
step :other do
type :text
question "Hello"
end
end
# => raises FlowEngine::DefinitionErrorValidation
The engine accepts a pluggable validator via the adapter pattern. The core gem ships with a NullAdapter (always passes) and defines the interface for custom adapters:
# The adapter interface
class FlowEngine::Validation::Adapter
def validate(node, input)
# Must return a FlowEngine::Validation::Result
raise NotImplementedError
end
end
# Result object
FlowEngine::Validation::Result.new(valid: true, errors: [])
FlowEngine::Validation::Result.new(valid: false, errors: ["must be a number"])Custom Validator Example
class MyValidator < FlowEngine::Validation::Adapter
def validate(node, input)
errors = []
case node.type
when :number
errors << "must be a number" unless input.is_a?(Numeric)
when :single_select
errors << "invalid option" unless node.options&.include?(input)
when :multi_select
unless input.is_a?(Array) && input.all? { |v| node.options&.include?(v) }
errors << "invalid options"
end
end
FlowEngine::Validation::Result.new(valid: errors.empty?, errors: errors)
end
end
engine = FlowEngine::Engine.new(definition, validator: MyValidator.new)
engine.answer("not_a_number") # => raises FlowEngine::ValidationErrorMermaid Diagram Export
Export any flow definition as a Mermaid flowchart:
exporter = FlowEngine::Graph::MermaidExporter.new(definition)
puts exporter.exportOutput:
flowchart TD
name["What is your name?"]
name --> age
age["How old are you?"]
age -->|"age > 20"| beverage
age --> thanks
beverage["Pick a drink."]
beverage --> thanks
thanks["Thank you for your responses!"]
Complete Example: Tax Intake Flow
Here's a realistic 17-step tax intake wizard that demonstrates every feature of the DSL.
Flow Definition
tax_intake = FlowEngine.define do
start :filing_status
step :filing_status do
type :single_select
question "What is your filing status for 2025?"
options %w[single married_filing_jointly married_filing_separately head_of_household]
transition to: :dependents
end
step :dependents do
type :number
question "How many dependents do you have?"
transition to: :income_types
end
step :income_types do
type :multi_select
question "Select all income types that apply to you in 2025."
options %w[W2 1099 Business Investment Rental Retirement]
transition to: :business_count, if_rule: contains(:income_types, "Business")
transition to: :investment_details, if_rule: contains(:income_types, "Investment")
transition to: :rental_details, if_rule: contains(:income_types, "Rental")
transition to: :state_filing
end
step :business_count do
type :number
question "How many total businesses do you own or are a partner in?"
transition to: :complex_business_info, if_rule: greater_than(:business_count, 2)
transition to: :business_details
end
step :complex_business_info do
type :text
question "With more than 2 businesses, please provide your primary EIN and a brief description of each entity."
transition to: :business_details
end
step :business_details do
type :number_matrix
question "How many of each business type do you own?"
fields %w[RealEstate SCorp CCorp Trust LLC]
transition to: :investment_details, if_rule: contains(:income_types, "Investment")
transition to: :rental_details, if_rule: contains(:income_types, "Rental")
transition to: :state_filing
end
step :investment_details do
type :multi_select
question "What types of investments do you hold?"
options %w[Stocks Bonds Crypto RealEstate MutualFunds]
transition to: :crypto_details, if_rule: contains(:investment_details, "Crypto")
transition to: :rental_details, if_rule: contains(:income_types, "Rental")
transition to: :state_filing
end
step :crypto_details do
type :text
question "Please describe your cryptocurrency transactions (exchanges used, approximate number of transactions)."
transition to: :rental_details, if_rule: contains(:income_types, "Rental")
transition to: :state_filing
end
step :rental_details do
type :number_matrix
question "Provide details about your rental properties."
fields %w[Residential Commercial Vacation]
transition to: :state_filing
end
step :state_filing do
type :multi_select
question "Which states do you need to file in?"
options %w[California NewYork Texas Florida Illinois Other]
transition to: :foreign_accounts
end
step :foreign_accounts do
type :single_select
question "Do you have any foreign financial accounts (bank accounts, securities, or financial assets)?"
options %w[yes no]
transition to: :foreign_account_details, if_rule: equals(:foreign_accounts, "yes")
transition to: :deduction_types
end
step :foreign_account_details do
type :number
question "How many foreign accounts do you have?"
transition to: :deduction_types
end
step :deduction_types do
type :multi_select
question "Which additional deductions apply to you?"
options %w[Medical Charitable Education Mortgage None]
transition to: :charitable_amount, if_rule: contains(:deduction_types, "Charitable")
transition to: :contact_info
end
step :charitable_amount do
type :number
question "What is your total estimated charitable contribution amount for 2025?"
transition to: :charitable_documentation, if_rule: greater_than(:charitable_amount, 5000)
transition to: :contact_info
end
step :charitable_documentation do
type :text
question "For charitable contributions over $5,000, please list the organizations and amounts."
transition to: :contact_info
end
step :contact_info do
type :text
question "Please provide your contact information (name, email, phone)."
transition to: :review
end
step :review do
type :text
question "Thank you! Please review your information. Type 'confirm' to submit."
end
endScenario 1: Maximum Path (17 steps visited)
A married filer with all income types, 4 businesses, crypto, rentals, foreign accounts, and high charitable giving:
engine = FlowEngine::Engine.new(tax_intake)
engine.answer("married_filing_jointly")
engine.answer(3)
engine.answer(%w[W2 1099 Business Investment Rental Retirement])
engine.answer(4)
engine.answer("EIN: 12-3456789. Entities: Alpha LLC, Beta SCorp, Gamma LLC, Delta CCorp")
engine.answer({ "RealEstate" => 1, "SCorp" => 1, "CCorp" => 1, "Trust" => 0, "LLC" => 2 })
engine.answer(%w[Stocks Bonds Crypto RealEstate])
engine.answer("Coinbase and Kraken, approximately 150 transactions in 2025")
engine.answer({ "Residential" => 2, "Commercial" => 1, "Vacation" => 0 })
engine.answer(%w[California NewYork])
engine.answer("yes")
engine.answer(3)
engine.answer(%w[Medical Charitable Education Mortgage])
engine.answer(12_000)
engine.answer("Red Cross: $5,000; Habitat for Humanity: $4,000; Local Food Bank: $3,000")
engine.answer("Jane Smith, jane.smith@example.com, 555-123-4567")
engine.answer("confirm")Collected data:
{
"filing_status": "married_filing_jointly",
"dependents": 3,
"income_types": ["W2", "1099", "Business", "Investment", "Rental", "Retirement"],
"business_count": 4,
"complex_business_info": "EIN: 12-3456789. Entities: Alpha LLC, Beta SCorp, Gamma LLC, Delta CCorp",
"business_details": {
"RealEstate": 1,
"SCorp": 1,
"CCorp": 1,
"Trust": 0,
"LLC": 2
},
"investment_details": ["Stocks", "Bonds", "Crypto", "RealEstate"],
"crypto_details": "Coinbase and Kraken, approximately 150 transactions in 2025",
"rental_details": {
"Residential": 2,
"Commercial": 1,
"Vacation": 0
},
"state_filing": ["California", "NewYork"],
"foreign_accounts": "yes",
"foreign_account_details": 3,
"deduction_types": ["Medical", "Charitable", "Education", "Mortgage"],
"charitable_amount": 12000,
"charitable_documentation": "Red Cross: $5,000; Habitat for Humanity: $4,000; Local Food Bank: $3,000",
"contact_info": "Jane Smith, jane.smith@example.com, 555-123-4567",
"review": "confirm"
}Path taken (all 17 steps):
filing_status -> dependents -> income_types -> business_count ->
complex_business_info -> business_details -> investment_details ->
crypto_details -> rental_details -> state_filing -> foreign_accounts ->
foreign_account_details -> deduction_types -> charitable_amount ->
charitable_documentation -> contact_info -> review
Scenario 2: Minimum Path (8 steps visited)
A single filer, W2 income only, no special deductions:
engine = FlowEngine::Engine.new(tax_intake)
engine.answer("single")
engine.answer(0)
engine.answer(["W2"])
engine.answer(["Texas"])
engine.answer("no")
engine.answer(["None"])
engine.answer("John Doe, john.doe@example.com, 555-987-6543")
engine.answer("confirm")Collected data:
{
"filing_status": "single",
"dependents": 0,
"income_types": ["W2"],
"state_filing": ["Texas"],
"foreign_accounts": "no",
"deduction_types": ["None"],
"contact_info": "John Doe, john.doe@example.com, 555-987-6543",
"review": "confirm"
}Path taken (8 steps — skipped 9 steps):
filing_status -> dependents -> income_types -> state_filing ->
foreign_accounts -> deduction_types -> contact_info -> review
Skipped: business_count, complex_business_info, business_details, investment_details, crypto_details, rental_details, foreign_account_details, charitable_amount, charitable_documentation.
Scenario 3: Medium Path (12 steps visited)
Married, with business + investment income, low charitable giving:
engine = FlowEngine::Engine.new(tax_intake)
engine.answer("married_filing_separately")
engine.answer(1)
engine.answer(%w[W2 Business Investment])
engine.answer(2) # <= 2 businesses, no complex_business_info
engine.answer({ "RealEstate" => 0, "SCorp" => 1, "CCorp" => 0, "Trust" => 0, "LLC" => 1 })
engine.answer(%w[Stocks Bonds MutualFunds]) # no Crypto, no crypto_details
engine.answer(%w[California Illinois])
engine.answer("no") # no foreign accounts
engine.answer(%w[Charitable Mortgage])
engine.answer(3000) # <= 5000, no documentation needed
engine.answer("Alice Johnson, alice.j@example.com, 555-555-0100")
engine.answer("confirm")Collected data:
{
"filing_status": "married_filing_separately",
"dependents": 1,
"income_types": ["W2", "Business", "Investment"],
"business_count": 2,
"business_details": {
"RealEstate": 0,
"SCorp": 1,
"CCorp": 0,
"Trust": 0,
"LLC": 1
},
"investment_details": ["Stocks", "Bonds", "MutualFunds"],
"state_filing": ["California", "Illinois"],
"foreign_accounts": "no",
"deduction_types": ["Charitable", "Mortgage"],
"charitable_amount": 3000,
"contact_info": "Alice Johnson, alice.j@example.com, 555-555-0100",
"review": "confirm"
}Path taken (12 steps):
filing_status -> dependents -> income_types -> business_count ->
business_details -> investment_details -> state_filing ->
foreign_accounts -> deduction_types -> charitable_amount ->
contact_info -> review
Scenario 4: Rental + Foreign Accounts (10 steps visited)
Head of household, 1099 + rental income, foreign accounts, no charitable:
engine = FlowEngine::Engine.new(tax_intake)
engine.answer("head_of_household")
engine.answer(2)
engine.answer(%w[1099 Rental])
engine.answer({ "Residential" => 1, "Commercial" => 0, "Vacation" => 1 })
engine.answer(["Florida"])
engine.answer("yes")
engine.answer(1)
engine.answer(%w[Medical Education])
engine.answer("Bob Lee, bob@example.com, 555-000-1111")
engine.answer("confirm")Collected data:
{
"filing_status": "head_of_household",
"dependents": 2,
"income_types": ["1099", "Rental"],
"rental_details": {
"Residential": 1,
"Commercial": 0,
"Vacation": 1
},
"state_filing": ["Florida"],
"foreign_accounts": "yes",
"foreign_account_details": 1,
"deduction_types": ["Medical", "Education"],
"contact_info": "Bob Lee, bob@example.com, 555-000-1111",
"review": "confirm"
}Path taken (10 steps):
filing_status -> dependents -> income_types -> rental_details ->
state_filing -> foreign_accounts -> foreign_account_details ->
deduction_types -> contact_info -> review
Comparing Collected Data Across Paths
The shape of the collected data depends entirely on which path the user takes through the graph. Here's a side-by-side of which keys appear in each scenario:
| Answer Key | Max (17) | Min (8) | Medium (12) | Rental (10) |
|---|---|---|---|---|
filing_status |
x | x | x | x |
dependents |
x | x | x | x |
income_types |
x | x | x | x |
business_count |
x | x | ||
complex_business_info |
x | |||
business_details |
x | x | ||
investment_details |
x | x | ||
crypto_details |
x | |||
rental_details |
x | x | ||
state_filing |
x | x | x | x |
foreign_accounts |
x | x | x | x |
foreign_account_details |
x | x | ||
deduction_types |
x | x | x | x |
charitable_amount |
x | x | ||
charitable_documentation |
x | |||
contact_info |
x | x | x | x |
review |
x | x | x | x |
Composing Complex Rules
Example: Multi-Condition Branching
definition = FlowEngine.define do
start :status
step :status do
type :single_select
question "Filing status?"
options %w[single married]
transition to: :dependents
end
step :dependents do
type :number
question "How many dependents?"
transition to: :income
end
step :income do
type :multi_select
question "Income types?"
options %w[W2 Business]
# All three conditions must be true
transition to: :special_review,
if_rule: all(
equals(:status, "married"),
contains(:income, "Business"),
not_empty(:income)
)
# At least one condition must be true
transition to: :alt_review,
if_rule: any(
less_than(:dependents, 2),
contains(:income, "W2")
)
# Unconditional fallback
transition to: :default_review
end
step :special_review do
type :text
question "Married with business income - special review required."
end
step :alt_review do
type :text
question "Alternative review path."
end
step :default_review do
type :text
question "Default review."
end
endThe four possible outcomes:
| Inputs | Path | Why |
|---|---|---|
married, 0 deps, [W2, Business]
|
:special_review |
all() satisfied: married + Business + not empty |
single, 0 deps, [W2]
|
:alt_review |
all() fails (not married); any() passes (has W2) |
single, 1 dep, [Business]
|
:alt_review |
all() fails; any() passes (deps < 2) |
single, 3 deps, [Business]
|
:default_review |
all() fails; any() fails (deps not < 2, no W2) |
Mermaid Diagram of the Tax Intake Flow
flowchart BT
filing_status["What is your filing status for 2025?"] --> dependents["How many dependents do you have?"]
dependents --> income_types["Select all income types that apply to you in 2025."]
income_types -- Business in income_types --> business_count["How many total businesses do you own or are a part..."]
income_types -- Investment in income_types --> investment_details["What types of investments do you hold?"]
income_types -- Rental in income_types --> rental_details["Provide details about your rental properties."]
income_types --> state_filing["Which states do you need to file in?"]
business_count -- business_count > 2 --> complex_business_info["With more than 2 businesses, please provide your p..."]
business_count --> business_details["How many of each business type do you own?"]
complex_business_info --> business_details
business_details -- Investment in income_types --> investment_details
business_details -- Rental in income_types --> rental_details
business_details --> state_filing
investment_details -- Crypto in investment_details --> crypto_details["Please describe your cryptocurrency transactions (..."]
investment_details -- Rental in income_types --> rental_details
investment_details --> state_filing
crypto_details -- Rental in income_types --> rental_details
crypto_details --> state_filing
rental_details --> state_filing
state_filing --> foreign_accounts["Do you have any foreign financial accounts (bank a..."]
foreign_accounts -- "foreign_accounts == yes" --> foreign_account_details["How many foreign accounts do you have?"]
foreign_accounts --> deduction_types["Which additional deductions apply to you?"]
foreign_account_details --> deduction_types
deduction_types -- Charitable in deduction_types --> charitable_amount["What is your total estimated charitable contributi..."]
deduction_types --> contact_info["Please provide your contact information (name, ema..."]
charitable_amount -- charitable_amount > 5000 --> charitable_documentation["For charitable contributions over $5,000, please l..."]
charitable_amount --> contact_info
charitable_documentation --> contact_info
contact_info --> review@{ label: "Thank you! Please review your information. Type 'c..." }
review@{ shape: rect}
Ecosystem
FlowEngine is the core of a three-gem architecture:
| Gem | Purpose |
|---|---|
flowengine (this gem) |
Core engine — pure Ruby, no Rails, no DB, no UI |
flowengine-cli |
Terminal wizard adapter using TTY Toolkit + Dry::CLI |
flowengine-rails |
Rails Engine with ActiveRecord persistence and web views |
Development
bundle install
bundle exec rspecLicense
The gem is available as open source under the terms of the MIT License.
