easy_flow 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 (47) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +2 -0
  4. data/Rakefile +6 -0
  5. data/app/models/easy_flow/admission.rb +17 -0
  6. data/app/models/easy_flow/application_record.rb +5 -0
  7. data/app/models/easy_flow/asked.rb +3 -0
  8. data/app/models/easy_flow/canvas.rb +165 -0
  9. data/app/models/easy_flow/change.rb +16 -0
  10. data/app/models/easy_flow/choice.rb +7 -0
  11. data/app/models/easy_flow/condition.rb +22 -0
  12. data/app/models/easy_flow/definition.rb +125 -0
  13. data/app/models/easy_flow/digest.rb +114 -0
  14. data/app/models/easy_flow/document.rb +129 -0
  15. data/app/models/easy_flow/drawing.rb +13 -0
  16. data/app/models/easy_flow/edge.rb +7 -0
  17. data/app/models/easy_flow/edit_history.rb +73 -0
  18. data/app/models/easy_flow/layout.rb +86 -0
  19. data/app/models/easy_flow/name.rb +13 -0
  20. data/app/models/easy_flow/node.rb +3 -0
  21. data/app/models/easy_flow/output.rb +21 -0
  22. data/app/models/easy_flow/progress/kept.rb +29 -0
  23. data/app/models/easy_flow/progress/loose.rb +35 -0
  24. data/app/models/easy_flow/progress.rb +9 -0
  25. data/app/models/easy_flow/question_runner.rb +13 -0
  26. data/app/models/easy_flow/registry.rb +23 -0
  27. data/app/models/easy_flow/run.rb +46 -0
  28. data/app/models/easy_flow/runner.rb +63 -0
  29. data/app/models/easy_flow/settings.rb +78 -0
  30. data/app/models/easy_flow/start.rb +11 -0
  31. data/app/models/easy_flow/step.rb +40 -0
  32. data/app/models/easy_flow/step_type/declaration.rb +116 -0
  33. data/app/models/easy_flow/step_type.rb +67 -0
  34. data/app/models/easy_flow/steps/question.rb +52 -0
  35. data/app/models/easy_flow/switch.rb +15 -0
  36. data/app/models/easy_flow/terminal.rb +11 -0
  37. data/app/models/easy_flow/validator.rb +169 -0
  38. data/app/models/easy_flow/version.rb +24 -0
  39. data/app/models/easy_flow/violation.rb +7 -0
  40. data/config/routes.rb +2 -0
  41. data/db/migrate/20260924120000_create_easy_flow_definitions.rb +20 -0
  42. data/db/migrate/20260924120100_create_easy_flow_versions.rb +16 -0
  43. data/db/migrate/20260924120200_create_easy_flow_runs.rb +13 -0
  44. data/lib/easy_flow/engine.rb +20 -0
  45. data/lib/easy_flow/version.rb +3 -0
  46. data/lib/easy_flow.rb +50 -0
  47. metadata +103 -0
@@ -0,0 +1,67 @@
1
+ module EasyFlow
2
+ class StepType
3
+ def self.define(id, &declaration)
4
+ Declaration.new(id).tap { |decl| decl.instance_eval(&declaration) }.to_step_type
5
+ end
6
+
7
+ attr_reader :id, :step_name, :settings, :naming_field, :naming, :outputs, :drawn_by
8
+
9
+ def initialize(id:, step_name:, settings:, awaits_input:, behaviour:, routing:,
10
+ ends_here: false, begins_here: false, display: nil, drawn_by: nil,
11
+ naming_field: nil, naming: nil, outputs: [])
12
+ @id = id
13
+ @step_name = step_name
14
+ @settings = settings
15
+ @awaits_input = awaits_input
16
+ @ends_here = ends_here
17
+ @begins_here = begins_here
18
+ @behaviour = behaviour
19
+ @routing = routing
20
+ @display = display
21
+ @drawn_by = drawn_by
22
+ @naming_field = naming_field
23
+ @naming = naming
24
+ @outputs = outputs
25
+ end
26
+
27
+ def display_of(node)
28
+ @display&.call(node)
29
+ end
30
+
31
+ def process(node, state)
32
+ @behaviour&.call(node, state)
33
+ end
34
+
35
+ def route(node, state)
36
+ @routing&.call(node, state)
37
+ end
38
+
39
+ def name_of(node)
40
+ @naming&.call(node).presence || node.config[naming_field.to_s].presence
41
+ end
42
+
43
+ def acts?
44
+ @behaviour.present?
45
+ end
46
+
47
+ def routes?
48
+ @routing.present?
49
+ end
50
+
51
+ def values_of(name, node)
52
+ outputs.find { |output| output.name.to_s == name.to_s }&.values_for(node).to_a
53
+ end
54
+
55
+ def awaits_input?
56
+ @awaits_input
57
+ end
58
+
59
+ def ends_here?
60
+ @ends_here
61
+ end
62
+
63
+ def begins_here?
64
+ @begins_here
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,52 @@
1
+ module EasyFlow
2
+ module Steps
3
+ class Question
4
+ include Step
5
+
6
+ step_name "Question"
7
+
8
+ setting :question, type: :string
9
+ setting :category, type: :string
10
+ setting :answers, type: :list, required: true do
11
+ setting :value, type: :string
12
+ setting :label, type: :string
13
+ setting :weight, type: :integer
14
+ end
15
+
16
+ output :answer, type: :string, label: "Answer", values: ->(node) { Question.offered(node.config) }
17
+
18
+ names_by :question
19
+ awaits_input
20
+
21
+ displays_by { |node| Asked.new(id: node.id.to_sym, text: asked(node.config), choices: choices_in(node)) }
22
+
23
+ def self.asked(step)
24
+ step.to_h["question"] || step.to_h["text"]
25
+ end
26
+
27
+ def self.answers_of(step)
28
+ Array(step.to_h["answers"] || step.to_h["options"])
29
+ end
30
+
31
+ def self.offered(step)
32
+ answers_of(step).map do |answer|
33
+ { "value" => answer["value"], "label" => answer["label"].presence || answer["value"] }
34
+ end
35
+ end
36
+
37
+ def self.choices_in(node)
38
+ answers_of(node.config).map { |answer| choice_from(answer) }
39
+ end
40
+
41
+ def self.choice_from(answer)
42
+ return Choice.new(value: answer) unless answer.is_a?(Hash)
43
+
44
+ Choice.new(value: answer["value"], label: answer["label"], hint: answer["hint"])
45
+ end
46
+
47
+ def self.category_of(step)
48
+ step.to_h["category"] || step.to_h["tag"]
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,15 @@
1
+ module EasyFlow
2
+ class Switch
3
+ include Step
4
+
5
+ step_name "Switch"
6
+
7
+ setting :step, type: :previous_step
8
+
9
+ output :choice, type: :string, label: "Choice", from: :step
10
+
11
+ def route(node, state)
12
+ state[node.config["step"]]
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,11 @@
1
+ module EasyFlow
2
+ class Terminal
3
+ include Step
4
+
5
+ step_name "End"
6
+
7
+ ends_here
8
+
9
+ names_by { |_node| "End" }
10
+ end
11
+ end
@@ -0,0 +1,169 @@
1
+ module EasyFlow
2
+ class Validator
3
+ OPTIONAL = { unrouted_value: :unrouted_values, unfollowed_path: :unfollowed_paths, dead_end: :dead_ends }.freeze
4
+
5
+ def initialize(document, registry: EasyFlow.registry, checks: EasyFlow.checks)
6
+ @document = document
7
+ @registry = registry
8
+ @checks = checks
9
+ end
10
+
11
+ def violations
12
+ structural_violations + unmet_requirements + missing_settings + missing_values + beginnings + asked_for
13
+ end
14
+
15
+ def structural_violations
16
+ malformations + unreachable
17
+ end
18
+
19
+ def malformations
20
+ missing_edge_targets + missing_edge_sources + duplicate_ids + no_beginning
21
+ end
22
+
23
+ private
24
+
25
+ def asked_for
26
+ @checks.flat_map { |name| send(OPTIONAL.fetch(name)) }
27
+ end
28
+
29
+ def missing_edge_targets
30
+ @document.edges.reject { |edge| known?(edge.to) }
31
+ .map { |edge| Violation.new(node: edge.from, problem: :missing_edge_target, detail: edge.to) }
32
+ end
33
+
34
+ def missing_edge_sources
35
+ @document.edges.reject { |edge| known?(edge.from) }
36
+ .map { |edge| Violation.new(node: edge.to, problem: :missing_edge_source, detail: edge.from) }
37
+ end
38
+
39
+ def duplicate_ids
40
+ @document.nodes.map(&:id).tally.select { |_id, count| count > 1 }
41
+ .map { |id, _count| Violation.new(node: id, problem: :duplicate_id) }
42
+ end
43
+
44
+ def no_beginning
45
+ return [] if known?(@document.entry)
46
+
47
+ [ Violation.new(node: nil, problem: :no_beginning) ]
48
+ end
49
+
50
+ def unmet_requirements
51
+ @document.nodes.flat_map do |node|
52
+ requirements_for(node).reject { |required| precedes_every_path?(required, node.id) }
53
+ .map { |required| Violation.new(node: node.id, problem: :unmet_requirement, detail: required) }
54
+ end
55
+ end
56
+
57
+ def dead_ends
58
+ @document.nodes.filter_map { |node| ending_problem(node) }
59
+ end
60
+
61
+ def ending_problem(node)
62
+ leaving = @document.edges_from(node.id).any?
63
+ return Violation.new(node: node.id, problem: :past_the_end) if ends_here?(node) && leaving
64
+ return if ends_here?(node) || leaving
65
+
66
+ Violation.new(node: node.id, problem: :dead_end)
67
+ end
68
+
69
+ def ends_here?(node)
70
+ @registry.registered?(node.type) && @registry.fetch(node.type).ends_here?
71
+ end
72
+
73
+ def unfollowed_paths
74
+ @document.nodes.select { |node| leads_on?(node) }.flat_map { |node| unfollowed_paths_from(node) }
75
+ end
76
+
77
+ def leads_on?(node)
78
+ @registry.registered?(node.type) && !@registry.fetch(node.type).routes?
79
+ end
80
+
81
+ def unfollowed_paths_from(node)
82
+ @document.edges_from(node.id).drop(1)
83
+ .map { |edge| Violation.new(node: node.id, problem: :unfollowed_path, detail: edge.to) }
84
+ end
85
+
86
+ def unrouted_values
87
+ @document.nodes.flat_map { |node| unrouted_values_in(node) }
88
+ end
89
+
90
+ def unrouted_values_in(node)
91
+ wired = @document.edges_from(node.id).map { |edge| edge.on.to_s }
92
+
93
+ digest.routing_values(node.id).reject { |value| wired.include?(value) }
94
+ .map { |value| Violation.new(node: node.id, problem: :unrouted_value, detail: value) }
95
+ end
96
+
97
+ def digest
98
+ Digest.new(@document, registry: @registry)
99
+ end
100
+
101
+ def beginnings
102
+ @document.beginnings.drop(1).map { |id| Violation.new(node: id, problem: :many_beginnings) } + led_into
103
+ end
104
+
105
+ def led_into
106
+ @document.beginnings.select { |id| @document.edges.any? { |edge| edge.to == id } }
107
+ .map { |id| Violation.new(node: id, problem: :before_the_beginning) }
108
+ end
109
+
110
+ def missing_values
111
+ @document.nodes.flat_map { |node| missing_values_in(node) }
112
+ end
113
+
114
+ def missing_values_in(node)
115
+ drawn_of(node).filter_map do |name, source|
116
+ chosen = node.config[name.to_s]
117
+ next if chosen.blank? || node.config[source.to_s].blank?
118
+ next if offered_to(node, source).include?(chosen.to_s)
119
+
120
+ Violation.new(node: node.id, problem: :missing_value, detail: chosen.to_s)
121
+ end
122
+ end
123
+
124
+ def offered_to(node, source)
125
+ naming = @registry.fetch(node.type).settings.outputs_of[source]
126
+
127
+ digest.values_of(node.config[naming.to_s], node.config[source.to_s]).map { |value| value["value"].to_s }
128
+ end
129
+
130
+ def drawn_of(node)
131
+ return {} unless @registry.registered?(node.type)
132
+
133
+ @registry.fetch(node.type).settings.drawn_from
134
+ end
135
+
136
+ def missing_settings
137
+ @document.nodes.flat_map do |node|
138
+ required_of(node).reject { |name| node.config[name.to_s].present? }
139
+ .map { |name| Violation.new(node: node.id, problem: :missing_setting, detail: name.to_s) }
140
+ end
141
+ end
142
+
143
+ def required_of(node)
144
+ return [] unless @registry.registered?(node.type)
145
+
146
+ @registry.fetch(node.type).settings.required
147
+ end
148
+
149
+ def requirements_for(node)
150
+ return [] unless @registry.registered?(node.type)
151
+
152
+ @registry.fetch(node.type).settings.requirements_for(node.config)
153
+ end
154
+
155
+ def precedes_every_path?(required, id)
156
+ known?(required) && !@document.reachable(without: required).include?(id)
157
+ end
158
+
159
+ def unreachable
160
+ return [] unless known?(@document.entry)
161
+
162
+ (@document.nodes.map(&:id).uniq - @document.reachable).map { |id| Violation.new(node: id, problem: :unreachable) }
163
+ end
164
+
165
+ def known?(id)
166
+ @document.node(id).present?
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,24 @@
1
+ module EasyFlow
2
+ class Version < ApplicationRecord
3
+ self.table_name = "easy_flow_versions"
4
+
5
+ enum :status, { draft: "draft", live: "live", superseded: "superseded",
6
+ retired: "retired", withdrawn: "withdrawn" }
7
+
8
+ belongs_to :flow, class_name: "EasyFlow::Definition"
9
+
10
+ validates :number, uniqueness: { scope: :flow_id }
11
+
12
+ def out_of_service?
13
+ retired? || withdrawn?
14
+ end
15
+
16
+ def changes
17
+ changes_captured.to_a
18
+ end
19
+
20
+ FROZEN = %w[definition number flow_id changes_captured].freeze
21
+
22
+ before_update { raise ActiveRecord::ReadOnlyRecord if changed.intersect?(FROZEN) }
23
+ end
24
+ end
@@ -0,0 +1,7 @@
1
+ module EasyFlow
2
+ Violation = Data.define(:node, :problem, :detail) do
3
+ def initialize(node:, problem:, detail: nil)
4
+ super
5
+ end
6
+ end
7
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,2 @@
1
+ EasyFlow::Engine.routes.draw do
2
+ end
@@ -0,0 +1,20 @@
1
+ class CreateEasyFlowDefinitions < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :easy_flow_definitions do |t|
4
+ t.string :slug
5
+ t.string :title
6
+ t.string :start_label
7
+ t.string :kind
8
+ t.string :status, null: false, default: "active"
9
+ t.string :persists, null: false, default: "unsaved"
10
+ t.json :document
11
+ t.integer :definition_cursor
12
+ t.json :changes_since_version
13
+ t.json :undo_history
14
+ t.json :undone_changes
15
+ t.timestamps
16
+ end
17
+
18
+ add_index :easy_flow_definitions, :slug, unique: true
19
+ end
20
+ end
@@ -0,0 +1,16 @@
1
+ class CreateEasyFlowVersions < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :easy_flow_versions do |t|
4
+ t.references :flow, null: false, foreign_key: { to_table: :easy_flow_definitions }
5
+ t.integer :number, null: false
6
+ t.json :definition
7
+ t.json :changes_captured
8
+ t.string :status, null: false, default: "draft"
9
+ t.datetime :created_at, null: false
10
+ end
11
+
12
+ add_index :easy_flow_versions, [ :flow_id, :number ], unique: true
13
+ add_index :easy_flow_versions, :flow_id, unique: true, where: "status = 'live'",
14
+ name: "index_easy_flow_versions_on_one_live_per_flow"
15
+ end
16
+ end
@@ -0,0 +1,13 @@
1
+ class CreateEasyFlowRuns < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :easy_flow_runs do |t|
4
+ t.references :flow, null: false, foreign_key: { to_table: :easy_flow_definitions }
5
+ t.references :definition_version, null: false, foreign_key: { to_table: :easy_flow_versions }
6
+ t.references :owner, polymorphic: true
7
+ t.json :recorded
8
+ t.string :label
9
+ t.string :status
10
+ t.timestamps
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,20 @@
1
+ module EasyFlow
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace EasyFlow
4
+
5
+ initializer "easy_flow.step_types" do |app|
6
+ app.config.to_prepare do
7
+ EasyFlow::Start.register
8
+ EasyFlow::Terminal.register
9
+
10
+ EasyFlow::Steps::Question.register
11
+ EasyFlow::Condition.register
12
+ EasyFlow::Switch.register
13
+
14
+ EasyFlow.check(:unrouted_value)
15
+ EasyFlow.check(:unfollowed_path)
16
+ EasyFlow.check(:dead_end)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,3 @@
1
+ module EasyFlow
2
+ VERSION = "0.1.0"
3
+ end
data/lib/easy_flow.rb ADDED
@@ -0,0 +1,50 @@
1
+ require "json"
2
+ require "easy_flow/version"
3
+ require "easy_flow/engine"
4
+
5
+ module EasyFlow
6
+ FIELD_TYPES = %i[string integer float boolean select multi_select previous_step from_step list].freeze
7
+
8
+ OUTPUT_TYPES = %i[string integer float boolean].freeze
9
+ OPTIONAL_CHECKS = %i[unrouted_value unfollowed_path dead_end].freeze
10
+
11
+ class UnknownFieldType < ArgumentError; end
12
+ class UnknownOutputType < ArgumentError; end
13
+ class UnknownStepType < KeyError; end
14
+ class UnknownCheck < ArgumentError; end
15
+ class InvalidEdit < StandardError; end
16
+ class NotPublished < StandardError; end
17
+ class NotPermitted < StandardError; end
18
+ class OutOfService < StandardError; end
19
+ class Withdrawn < StandardError; end
20
+
21
+ DRAWING = "easy_flow/steps/choosing".freeze
22
+
23
+ class << self
24
+ def draws_with(template)
25
+ @drawing = template
26
+ end
27
+
28
+ def drawing
29
+ @drawing.presence || DRAWING
30
+ end
31
+
32
+ def step(id, &declaration)
33
+ registry.register(StepType.define(id, &declaration))
34
+ end
35
+
36
+ def registry
37
+ @registry ||= Registry.new
38
+ end
39
+
40
+ def check(name)
41
+ raise UnknownCheck, "#{name} is not one of #{OPTIONAL_CHECKS.join(', ')}" unless OPTIONAL_CHECKS.include?(name)
42
+
43
+ checks << name unless checks.include?(name)
44
+ end
45
+
46
+ def checks
47
+ @checks ||= []
48
+ end
49
+ end
50
+ end
metadata ADDED
@@ -0,0 +1,103 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: easy_flow
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - tylercschneider
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 8.1.3
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 8.1.3
26
+ description: EasyFlow stores flows as versioned documents of steps and the connections
27
+ between them, and runs a visitor through the live version one step at a time. Apps
28
+ register their own step types.
29
+ email:
30
+ - tylercschneider@gmail.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - MIT-LICENSE
36
+ - README.md
37
+ - Rakefile
38
+ - app/models/easy_flow/admission.rb
39
+ - app/models/easy_flow/application_record.rb
40
+ - app/models/easy_flow/asked.rb
41
+ - app/models/easy_flow/canvas.rb
42
+ - app/models/easy_flow/change.rb
43
+ - app/models/easy_flow/choice.rb
44
+ - app/models/easy_flow/condition.rb
45
+ - app/models/easy_flow/definition.rb
46
+ - app/models/easy_flow/digest.rb
47
+ - app/models/easy_flow/document.rb
48
+ - app/models/easy_flow/drawing.rb
49
+ - app/models/easy_flow/edge.rb
50
+ - app/models/easy_flow/edit_history.rb
51
+ - app/models/easy_flow/layout.rb
52
+ - app/models/easy_flow/name.rb
53
+ - app/models/easy_flow/node.rb
54
+ - app/models/easy_flow/output.rb
55
+ - app/models/easy_flow/progress.rb
56
+ - app/models/easy_flow/progress/kept.rb
57
+ - app/models/easy_flow/progress/loose.rb
58
+ - app/models/easy_flow/question_runner.rb
59
+ - app/models/easy_flow/registry.rb
60
+ - app/models/easy_flow/run.rb
61
+ - app/models/easy_flow/runner.rb
62
+ - app/models/easy_flow/settings.rb
63
+ - app/models/easy_flow/start.rb
64
+ - app/models/easy_flow/step.rb
65
+ - app/models/easy_flow/step_type.rb
66
+ - app/models/easy_flow/step_type/declaration.rb
67
+ - app/models/easy_flow/steps/question.rb
68
+ - app/models/easy_flow/switch.rb
69
+ - app/models/easy_flow/terminal.rb
70
+ - app/models/easy_flow/validator.rb
71
+ - app/models/easy_flow/version.rb
72
+ - app/models/easy_flow/violation.rb
73
+ - config/routes.rb
74
+ - db/migrate/20260924120000_create_easy_flow_definitions.rb
75
+ - db/migrate/20260924120100_create_easy_flow_versions.rb
76
+ - db/migrate/20260924120200_create_easy_flow_runs.rb
77
+ - lib/easy_flow.rb
78
+ - lib/easy_flow/engine.rb
79
+ - lib/easy_flow/version.rb
80
+ homepage: https://github.com/DYB-Development/easy_flow
81
+ licenses:
82
+ - MIT
83
+ metadata:
84
+ homepage_uri: https://github.com/DYB-Development/easy_flow
85
+ source_code_uri: https://github.com/DYB-Development/easy_flow
86
+ rdoc_options: []
87
+ require_paths:
88
+ - lib
89
+ required_ruby_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ required_rubygems_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '0'
99
+ requirements: []
100
+ rubygems_version: 4.0.20
101
+ specification_version: 4
102
+ summary: Guided, versioned, branching flows for Rails apps.
103
+ test_files: []