flowengine-rails 0.1.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.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/branch-name.sh +49 -0
  3. data/.claude/commands/create-pr.md +93 -0
  4. data/.claude/commands/stash-unstaged.md +21 -0
  5. data/.claude/commands/unstash-unstaged.md +15 -0
  6. data/.claude/settings.json +72 -0
  7. data/.rubocop_todo.yml +17 -0
  8. data/.ruby-version +1 -0
  9. data/CHANGELOG.md +5 -0
  10. data/CLAUDE.md +153 -0
  11. data/LICENSE.txt +21 -0
  12. data/README.md +294 -0
  13. data/Rakefile +43 -0
  14. data/app/assets/javascripts/flow_engine/embed.js +44 -0
  15. data/app/assets/javascripts/flow_engine/progress_controller.js +17 -0
  16. data/app/assets/javascripts/flow_engine/step_controller.js +22 -0
  17. data/app/assets/stylesheets/flow_engine/application.css +569 -0
  18. data/app/controllers/flow_engine/admin/definitions_controller.rb +95 -0
  19. data/app/controllers/flow_engine/application_controller.rb +41 -0
  20. data/app/controllers/flow_engine/sessions_controller.rb +91 -0
  21. data/app/helpers/flow_engine/sessions_helper.rb +20 -0
  22. data/app/models/flow_engine/application_record.rb +8 -0
  23. data/app/models/flow_engine/flow_definition.rb +75 -0
  24. data/app/models/flow_engine/flow_session.rb +98 -0
  25. data/app/views/flow_engine/admin/definitions/_form.html.erb +27 -0
  26. data/app/views/flow_engine/admin/definitions/edit.html.erb +5 -0
  27. data/app/views/flow_engine/admin/definitions/index.html.erb +41 -0
  28. data/app/views/flow_engine/admin/definitions/mermaid.html.erb +9 -0
  29. data/app/views/flow_engine/admin/definitions/new.html.erb +5 -0
  30. data/app/views/flow_engine/admin/definitions/show.html.erb +25 -0
  31. data/app/views/flow_engine/sessions/completed.html.erb +34 -0
  32. data/app/views/flow_engine/sessions/new.html.erb +17 -0
  33. data/app/views/flow_engine/sessions/show.html.erb +26 -0
  34. data/app/views/flow_engine/sessions/steps/_boolean.html.erb +10 -0
  35. data/app/views/flow_engine/sessions/steps/_display.html.erb +4 -0
  36. data/app/views/flow_engine/sessions/steps/_multi_select.html.erb +8 -0
  37. data/app/views/flow_engine/sessions/steps/_number.html.erb +3 -0
  38. data/app/views/flow_engine/sessions/steps/_number_matrix.html.erb +13 -0
  39. data/app/views/flow_engine/sessions/steps/_single_select.html.erb +8 -0
  40. data/app/views/flow_engine/sessions/steps/_text.html.erb +11 -0
  41. data/app/views/flow_engine/sessions/steps/_unknown.html.erb +4 -0
  42. data/app/views/layouts/flow_engine/application.html.erb +26 -0
  43. data/app/views/layouts/flow_engine/embed.html.erb +30 -0
  44. data/config/routes.rb +22 -0
  45. data/db/migrate/01_create_flow_engine_definitions.rb +18 -0
  46. data/db/migrate/02_create_flow_engine_sessions.rb +18 -0
  47. data/exe/flowengine-rails +4 -0
  48. data/justfile +49 -0
  49. data/lefthook.yml +16 -0
  50. data/lib/flowengine/rails/configuration.rb +23 -0
  51. data/lib/flowengine/rails/dsl_loader.rb +35 -0
  52. data/lib/flowengine/rails/engine.rb +26 -0
  53. data/lib/flowengine/rails/version.rb +7 -0
  54. data/lib/flowengine/rails.rb +27 -0
  55. data/lib/generators/flow_engine/flow/flow_generator.rb +29 -0
  56. data/lib/generators/flow_engine/flow/templates/flow_definition.rb.tt +27 -0
  57. data/lib/generators/flow_engine/flow/templates/seed_task.rake.tt +22 -0
  58. data/lib/generators/flow_engine/install/install_generator.rb +34 -0
  59. data/lib/generators/flow_engine/install/templates/initializer.rb +25 -0
  60. data/log/.gitkeep +0 -0
  61. data/sig/flowengine/rails.rbs +6 -0
  62. metadata +164 -0
data/README.md ADDED
@@ -0,0 +1,294 @@
1
+ # Flowengine::Rails
2
+
3
+ [![RSpec](https://github.com/kigster/flowengine-rail/actions/workflows/rspec.yml/badge.svg)](https://github.com/kigster/flowengine-rail/actions/workflows/rspec.yml)   [![RuboCop](https://github.com/kigster/flowengine-rail/actions/workflows/rubocop.yml/badge.svg)](https://github.com/kigster/flowengine-rail/actions/workflows/rubocop.yml)   [![Default Rake Task](https://github.com/kigster/flowengine-rail/actions/workflows/main.yml/badge.svg)](https://github.com/kigster/flowengine-rail/actions/workflows/main.yml)
4
+
5
+ ## Introduction
6
+
7
+ **FlowEngine::Rails** is a Rails Engine that wraps the [`flowengine`](https://github.com/kigster/flowengine) core gem with ActiveRecord persistence, a Hotwire-based wizard UI, an admin CRUD interface, and an iframe-embeddable widget. It allows non-technical users to define multi-step form flows via a Ruby DSL, store them in the database, and serve them as interactive step-by-step wizards.
8
+
9
+ Flows support seven step types: `text`, `number`, `boolean`, `single_select`, `multi_select`, `number_matrix`, and `display`. Conditional transitions between steps are driven by the core engine's rule evaluator (`if_rule:`, `contains()`, etc.).
10
+
11
+ ### Key features
12
+
13
+ - **Admin UI** -- full CRUD for flow definitions with DSL validation and Mermaid diagram visualization
14
+ - **Wizard UI** -- Hotwire/Turbo-powered step-by-step form sessions with progress tracking
15
+ - **Iframe embedding** -- embed flows on external sites with auto-resizing and completion callbacks
16
+ - **Versioning** -- definitions are automatically versioned; once sessions exist, the definition becomes immutable
17
+ - **Completion callbacks** -- fire custom logic when a user finishes a flow
18
+
19
+ ### Requirements
20
+
21
+ - Ruby >= 4.0.1
22
+ - Rails >= 8.1.2
23
+ - The [`flowengine`](https://github.com/kigster/flowengine) gem (`~> 0.1`)
24
+
25
+ ## Usage and Integration into a Rails Application
26
+
27
+ ### Installation
28
+
29
+ Add the gem to your `Gemfile`:
30
+
31
+ ```ruby
32
+ gem "flowengine-rails"
33
+ ```
34
+
35
+ Run the install generator, which copies migrations, creates an initializer, and mounts the engine:
36
+
37
+ ```bash
38
+ bin/rails generate flow_engine:install
39
+ bin/rails db:migrate
40
+ ```
41
+
42
+ This mounts the engine at `/flow_engine`. To change the mount point, edit `config/routes.rb`:
43
+
44
+ ```ruby
45
+ mount FlowEngine::Rails::Engine => "/wizards"
46
+ ```
47
+
48
+ ### Configuration
49
+
50
+ Edit `config/initializers/flow_engine.rb`:
51
+
52
+ ```ruby
53
+ FlowEngine::Rails.configure do |config|
54
+ # Origins allowed to embed flows via iframe (use ["*"] for all)
55
+ config.embed_allowed_origins = ["https://example.com"]
56
+
57
+ # Cache parsed DSL definitions in memory (recommended for production)
58
+ config.cache_definitions = Rails.env.production?
59
+
60
+ # Callback fired when a session completes
61
+ config.on_session_complete = ->(session) {
62
+ LeadNotifier.qualified(session).deliver_later
63
+ }
64
+
65
+ # Admin authentication -- method called as a before_action
66
+ config.admin_authentication_method = :authenticate_admin!
67
+ end
68
+ ```
69
+
70
+ ### Creating a flow definition
71
+
72
+ Use the generator to scaffold a flow definition file and a seed rake task:
73
+
74
+ ```bash
75
+ bin/rails generate flow_engine:flow lead_qualification
76
+ ```
77
+
78
+ This creates `db/flow_definitions/lead_qualification.rb` with a starter DSL template. Edit it to define your flow:
79
+
80
+ ```ruby
81
+ LEAD_QUALIFICATION_DSL = <<~RUBY
82
+ FlowEngine.define do
83
+ start :company_size
84
+
85
+ step :company_size do
86
+ type :single_select
87
+ question "How many employees does your company have?"
88
+ options ["1-10", "11-50", "51-200", "200+"]
89
+ transition to: :budget, if_rule: "contains(answer, '200')"
90
+ transition to: :industry
91
+ end
92
+
93
+ step :industry do
94
+ type :single_select
95
+ question "What industry are you in?"
96
+ options ["Technology", "Finance", "Healthcare", "Other"]
97
+ transition to: :budget
98
+ end
99
+
100
+ step :budget do
101
+ type :number
102
+ question "What is your annual budget for this service?"
103
+ transition to: :thank_you
104
+ end
105
+
106
+ step :thank_you do
107
+ type :display
108
+ question "Thank you! We will be in touch."
109
+ end
110
+ end
111
+ RUBY
112
+ ```
113
+
114
+ Seed it into the database:
115
+
116
+ ```bash
117
+ bin/rails flow_engine:seed:lead_qualification
118
+ ```
119
+
120
+ Or create definitions programmatically:
121
+
122
+ ```ruby
123
+ FlowEngine::FlowDefinition.create!(
124
+ name: "lead_qualification",
125
+ dsl: LEAD_QUALIFICATION_DSL
126
+ )
127
+ ```
128
+
129
+ The version auto-increments per name. To make it available to users, activate it:
130
+
131
+ ```ruby
132
+ FlowEngine::FlowDefinition.latest_version("lead_qualification").activate!
133
+ ```
134
+
135
+ ### Admin interface
136
+
137
+ The admin UI is available at `/flow_engine/admin/definitions`. To protect it, set `admin_authentication_method` in the initializer to a method defined on your `ApplicationController`:
138
+
139
+ ```ruby
140
+ # app/controllers/application_controller.rb
141
+ class ApplicationController < ActionController::Base
142
+ def authenticate_admin!
143
+ redirect_to root_path unless current_user&.admin?
144
+ end
145
+ end
146
+ ```
147
+
148
+ From the admin UI you can create, edit, activate/deactivate definitions, and view Mermaid flow diagrams.
149
+
150
+ ### Embedding flows on external sites
151
+
152
+ Add `embed.js` to the host page and call `FlowEngineEmbed.embed()`:
153
+
154
+ ```html
155
+ <script src="https://yourapp.com/flow_engine/assets/flow_engine/embed.js"></script>
156
+ <div id="flow-container"></div>
157
+ <script>
158
+ FlowEngineEmbed.embed({
159
+ target: "#flow-container",
160
+ definitionId: 1,
161
+ baseUrl: "https://yourapp.com/flow_engine",
162
+ onComplete: function(data) {
163
+ console.log("Flow completed:", data);
164
+ }
165
+ });
166
+ </script>
167
+ ```
168
+
169
+ The iframe auto-resizes to fit content. Make sure to add the host origin to `embed_allowed_origins` in the configuration.
170
+
171
+ ### Accessing session results
172
+
173
+ After a flow session completes, its answers are available as JSON:
174
+
175
+ ```ruby
176
+ session = FlowEngine::FlowSession.completed.last
177
+ session.result_json
178
+ # => { definition_name: "lead_qualification",
179
+ # definition_version: 1,
180
+ # answers: { "company_size" => "51-200", "budget" => 50000 },
181
+ # history: ["company_size", "industry", "budget", "thank_you"],
182
+ # status: "completed",
183
+ # completed_at: "2026-03-16T..." }
184
+ ```
185
+
186
+ ## Troubleshooting
187
+
188
+ ### DSL validation errors on save
189
+
190
+ The `FlowDefinition` model validates that the DSL string parses correctly before saving. If you see `"dsl is invalid"` errors, check that your DSL is valid Ruby and uses the correct `FlowEngine.define { ... }` syntax. Test your DSL in a console:
191
+
192
+ ```ruby
193
+ FlowEngine.load_dsl(your_dsl_string)
194
+ ```
195
+
196
+ ### "Cannot edit definition with existing sessions"
197
+
198
+ Once a `FlowDefinition` has associated `FlowSession` records, it becomes read-only to preserve data integrity. Create a new version instead:
199
+
200
+ ```ruby
201
+ old = FlowEngine::FlowDefinition.find(id)
202
+ FlowEngine::FlowDefinition.create!(name: old.name, dsl: updated_dsl)
203
+ ```
204
+
205
+ ### Iframe embedding returns no content
206
+
207
+ 1. Verify the host origin is listed in `config.embed_allowed_origins`
208
+ 1. Ensure the `?embed=true` query param is being passed (the embed script handles this automatically)
209
+ 1. Check browser console for CORS errors
210
+
211
+ ### Sessions stuck in `in_progress`
212
+
213
+ A session can be abandoned manually:
214
+
215
+ ```ruby
216
+ FlowEngine::FlowSession.find(id).abandon!
217
+ ```
218
+
219
+ To find stale sessions:
220
+
221
+ ```ruby
222
+ FlowEngine::FlowSession.in_progress.where("updated_at < ?", 24.hours.ago)
223
+ ```
224
+
225
+ ### Migration conflicts
226
+
227
+ If your app already has tables named `flow_engine_definitions` or `flow_engine_sessions`, the install migrations will conflict. Rename or drop the existing tables before running `bin/rails db:migrate`.
228
+
229
+ ## Development
230
+
231
+ ### Setup
232
+
233
+ ```bash
234
+ git clone https://github.com/kigster/flowengine-rails.git
235
+ cd flowengine-rails
236
+ bundle install
237
+ ```
238
+
239
+ The `flowengine` core gem is expected as a sibling directory for local development:
240
+
241
+ ```
242
+ flowengine-gems/
243
+ flowengine/ # core gem
244
+ flowengine-rails/ # this gem
245
+ ```
246
+
247
+ ### Running tests
248
+
249
+ Tests use an in-memory SQLite database -- no external database setup required:
250
+
251
+ ```bash
252
+ bundle exec rspec # run specs
253
+ bundle exec rubocop # run linter
254
+ bundle exec rake # both (default task)
255
+ ```
256
+
257
+ A dummy Rails app lives in `spec/dummy/` and is used by controller and integration specs.
258
+
259
+ ### Project structure
260
+
261
+ ```
262
+ app/
263
+ models/flow_engine/ # FlowDefinition, FlowSession
264
+ controllers/flow_engine/ # SessionsController, Admin::DefinitionsController
265
+ views/flow_engine/ # Wizard UI, admin UI, step partials
266
+ assets/ # embed.js, Stimulus controllers, CSS
267
+
268
+ lib/
269
+ flowengine/rails/ # Engine, Configuration, DslLoader
270
+ generators/flow_engine/ # install and flow generators
271
+
272
+ db/migrate/ # Definition and session tables
273
+ spec/ # RSpec suite with dummy app
274
+ ```
275
+
276
+ ### CI
277
+
278
+ Three GitHub Actions workflows run on push:
279
+
280
+ - **main.yml** -- `bundle exec rake` on Ruby 3.4.4
281
+ - **rspec.yml** -- `bundle exec rspec` on Ruby 4.0 (checks out `flowengine` core alongside)
282
+ - **rubocop.yml** -- `bundle exec rubocop` on Ruby 4.0
283
+
284
+ ### Contributing
285
+
286
+ 1. Fork the repo
287
+ 1. Create a feature branch (`git checkout -b feature/my-feature`)
288
+ 1. Commit your changes (keep commits atomic, one logical change per commit)
289
+ 1. Ensure `bundle exec rake` passes
290
+ 1. Open a pull request
291
+
292
+ ## License
293
+
294
+ Released under the [MIT License](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+ require "rubocop/rake_task"
6
+ require "timeout"
7
+ require "yard"
8
+
9
+ def shell(*args)
10
+ puts "running: #{args.join(" ")}"
11
+ system(args.join(" "))
12
+ end
13
+
14
+ desc "Clean all temporary files"
15
+ task :clean do
16
+ shell("rm -rf pkg/ tmp/ coverage/ doc/ ")
17
+ end
18
+
19
+ desc "Build the gem and install it locally"
20
+ task gem: [:build] do
21
+ shell("gem install pkg/*")
22
+ end
23
+
24
+ desc "Update unix permissions on the gem sources before packaging"
25
+ task permissions: [:clean] do
26
+ shell("chmod -v o+r,g+r * */* */*/* */*/*/* */*/*/*/* */*/*/*/*/*")
27
+ shell("find . -type d -exec chmod o+x,g+x {} \\;")
28
+ end
29
+
30
+ desc "Build the gem for distribution"
31
+ task build: :permissions
32
+
33
+ YARD::Rake::YardocTask.new(:doc) do |t|
34
+ t.files = %w[lib/**/*.rb exe/*.rb - README.md LICENSE.txt CHANGELOG.md]
35
+ t.options.unshift("--title", '"FlowEngine — DSL + AST for buildiong complex flows in Ruby."')
36
+ t.after = -> { exec("open doc/index.html") } if RUBY_PLATFORM =~ /darwin/
37
+ end
38
+
39
+ RSpec::Core::RakeTask.new(:spec)
40
+
41
+ RuboCop::RakeTask.new
42
+
43
+ task default: %i[spec rubocop]
@@ -0,0 +1,44 @@
1
+ // FlowEngine Embed Script
2
+ // Usage: <script src="/flow_engine/assets/flow_engine/embed.js"></script>
3
+ // Then: FlowEngine.embed({ target: '#container', definitionId: 1, baseUrl: '/flow_engine' })
4
+ (function(global) {
5
+ var FlowEngineEmbed = {
6
+ embed: function(options) {
7
+ var target = typeof options.target === 'string'
8
+ ? document.querySelector(options.target)
9
+ : options.target;
10
+
11
+ if (!target) {
12
+ console.error('FlowEngine: target element not found');
13
+ return;
14
+ }
15
+
16
+ var baseUrl = options.baseUrl || '/flow_engine';
17
+ var src = baseUrl + '/sessions/new?definition_id=' + options.definitionId + '&embed=true';
18
+
19
+ var iframe = document.createElement('iframe');
20
+ iframe.src = src;
21
+ iframe.style.width = '100%';
22
+ iframe.style.border = 'none';
23
+ iframe.style.minHeight = '400px';
24
+ iframe.setAttribute('frameborder', '0');
25
+
26
+ target.appendChild(iframe);
27
+
28
+ window.addEventListener('message', function(event) {
29
+ if (event.data && event.data.type === 'flowengine:resize') {
30
+ iframe.style.height = event.data.height + 'px';
31
+ }
32
+ if (event.data && event.data.type === 'flowengine:completed') {
33
+ if (typeof options.onComplete === 'function') {
34
+ options.onComplete(event.data);
35
+ }
36
+ }
37
+ });
38
+
39
+ return iframe;
40
+ }
41
+ };
42
+
43
+ global.FlowEngineEmbed = FlowEngineEmbed;
44
+ })(window);
@@ -0,0 +1,17 @@
1
+ // Stimulus controller for progress bar animation
2
+ // data-controller="progress" data-progress-value="75"
3
+ (function() {
4
+ if (typeof window.Stimulus !== 'undefined') {
5
+ window.Stimulus.register("progress", class extends window.Stimulus.Controller {
6
+ static values = { value: Number }
7
+
8
+ connect() {
9
+ const bar = this.element.querySelector('.fe-progress__bar');
10
+ if (bar) {
11
+ bar.style.transition = 'width 0.4s ease-in-out';
12
+ bar.style.width = this.valueValue + '%';
13
+ }
14
+ }
15
+ });
16
+ }
17
+ })();
@@ -0,0 +1,22 @@
1
+ // Stimulus controller for step forms — prevents double-submit
2
+ // data-controller="step"
3
+ (function() {
4
+ if (typeof window.Stimulus !== 'undefined') {
5
+ window.Stimulus.register("step", class extends window.Stimulus.Controller {
6
+ static targets = ["form", "submit"]
7
+
8
+ connect() {
9
+ if (this.hasFormTarget) {
10
+ this.formTarget.addEventListener('submit', this.disableSubmit.bind(this));
11
+ }
12
+ }
13
+
14
+ disableSubmit() {
15
+ if (this.hasSubmitTarget) {
16
+ this.submitTarget.disabled = true;
17
+ this.submitTarget.value = 'Submitting...';
18
+ }
19
+ }
20
+ });
21
+ }
22
+ })();