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,73 @@
1
+ module EasyFlow
2
+ class EditHistory
3
+ def initialize(flow)
4
+ @flow = flow
5
+ end
6
+
7
+ def undoable?
8
+ undoable.any?
9
+ end
10
+
11
+ def redoable?
12
+ undone.any?
13
+ end
14
+
15
+ def undoable
16
+ @flow.undo_history.to_a + @flow.changes_since_version.to_a
17
+ end
18
+
19
+ def undo_change
20
+ undone_change = undoable.last
21
+ return unless undone_change
22
+
23
+ @flow.update!(document: undone_change["before"],
24
+ undone_changes: undone + [ undone_change.merge("after" => @flow.document) ],
25
+ undo_history: @flow.undo_history.to_a[0...-1],
26
+ changes_since_version: @flow.changes_since_version.to_a[0...-1])
27
+ end
28
+
29
+ def redo_change
30
+ redone = undone.last
31
+ return unless redone
32
+
33
+ @flow.update!(document: redone["after"], undone_changes: undone[0...-1],
34
+ changes_since_version: @flow.changes_since_version.to_a + [ redone.except("after") ])
35
+ end
36
+
37
+ def record(edited, action, steps, before)
38
+ @flow.update!(document: edited.to_h, undone_changes: [],
39
+ changes_since_version: with(edited, action, steps, before))
40
+ end
41
+
42
+ def edit_document(payload)
43
+ @flow.update!(document: payload, undone_changes: [],
44
+ changes_since_version: @flow.changes_since_version.to_a + [ edited_by_hand ])
45
+ end
46
+
47
+ private
48
+
49
+ def with(edited, action, steps, before)
50
+ return @flow.changes_since_version.to_a unless action
51
+
52
+ @flow.changes_since_version.to_a +
53
+ [ { "action" => action.to_s, "steps" => steps.map(&:to_s),
54
+ "named" => steps.map { |id| named(edited, id) }, "before" => before } ]
55
+ end
56
+
57
+ def named(edited, id)
58
+ Name.of(edited.node(id.to_s) || document.node(id.to_s))
59
+ end
60
+
61
+ def document
62
+ Document.new(@flow.document || @flow.definition || {})
63
+ end
64
+
65
+ def undone
66
+ @flow.undone_changes.to_a
67
+ end
68
+
69
+ def edited_by_hand
70
+ { "action" => "edited", "steps" => [], "named" => [ "the definition" ], "before" => @flow.document }
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,86 @@
1
+ module EasyFlow
2
+ class Layout
3
+ SPREAD = 2
4
+
5
+ def initialize(document)
6
+ @document = document
7
+ end
8
+
9
+ def positions
10
+ rows = depths
11
+ columns = columns_for(rows)
12
+
13
+ rows.to_h { |id, row| [ id, { "row" => row, "column" => columns[id] } ] }
14
+ end
15
+
16
+ private
17
+
18
+ def depths
19
+ walked = walk_from_entry
20
+
21
+ walked.merge(stranded(walked).index_with(next_row(walked)))
22
+ end
23
+
24
+ def walk_from_entry
25
+ seen = {}
26
+ frontier = starting_points.map { |id| [ id, 0 ] }
27
+
28
+ until frontier.empty?
29
+ id, depth = frontier.shift
30
+ next if seen.key?(id)
31
+
32
+ seen[id] = depth
33
+ @document.edges_from(id).each { |edge| frontier << [ edge.to, depth + 1 ] if known?(edge.to) }
34
+ end
35
+
36
+ seen
37
+ end
38
+
39
+ def starting_points
40
+ return [ @document.entry ] if known?(@document.entry)
41
+
42
+ arrived = @document.edges.map(&:to)
43
+ @document.nodes.map(&:id).reject { |id| arrived.include?(id) }
44
+ end
45
+
46
+ def columns_for(rows)
47
+ @columns = {}
48
+ @next_leaf = 0
49
+ rows.keys.each { |id| place(id, branches(rows)) }
50
+ @columns
51
+ end
52
+
53
+ def branches(rows)
54
+ claimed = {}
55
+
56
+ rows.keys.to_h do |id|
57
+ mine = @document.edges_from(id).map(&:to).uniq
58
+ .select { |child| rows[child] == rows[id] + 1 && !claimed.key?(child) }
59
+ mine.each { |child| claimed[child] = true }
60
+ [ id, mine ]
61
+ end
62
+ end
63
+
64
+ def place(id, branches)
65
+ return @columns[id] if @columns.key?(id)
66
+
67
+ mine = branches[id].to_a
68
+ return @columns[id] = (@next_leaf += SPREAD) - SPREAD if mine.empty?
69
+
70
+ spans = mine.map { |child| place(child, branches) }
71
+ @columns[id] = (spans.min + spans.max) / 2
72
+ end
73
+
74
+ def stranded(walked)
75
+ @document.nodes.map(&:id).uniq - walked.keys
76
+ end
77
+
78
+ def next_row(walked)
79
+ (walked.values.max || -1) + 1
80
+ end
81
+
82
+ def known?(id)
83
+ @document.node(id).present?
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,13 @@
1
+ module EasyFlow
2
+ module Name
3
+ def self.of(node, registry = EasyFlow.registry)
4
+ return "" unless node
5
+
6
+ named(node, registry) || node.id
7
+ end
8
+
9
+ def self.named(node, registry)
10
+ registry.fetch(node.type).name_of(node) if registry.registered?(node.type)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,3 @@
1
+ module EasyFlow
2
+ Node = Data.define(:id, :type, :config)
3
+ end
@@ -0,0 +1,21 @@
1
+ module EasyFlow
2
+ Output = Data.define(:name, :label, :type, :values, :from) do
3
+ def initialize(name:, label: nil, type: :string, values: nil, from: nil)
4
+ raise UnknownOutputType, "#{type} is not one of #{OUTPUT_TYPES.join(', ')}" unless OUTPUT_TYPES.include?(type)
5
+
6
+ super(name: name, label: label.presence || name.to_s.humanize, type: type, values: values, from: from)
7
+ end
8
+
9
+ def values_for(node)
10
+ Array(values.respond_to?(:call) ? values.call(node) : values).map { |value| taken(value) }
11
+ end
12
+
13
+ private
14
+
15
+ def taken(value)
16
+ return value if value.is_a?(Hash)
17
+
18
+ { "value" => value, "label" => value.to_s }
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,29 @@
1
+ module EasyFlow
2
+ module Progress
3
+ class Kept
4
+ def initialize(run)
5
+ @run = run
6
+ end
7
+
8
+ def definition
9
+ @run.pinned_definition
10
+ end
11
+
12
+ def recorded
13
+ @run.recorded
14
+ end
15
+
16
+ def record(id, value)
17
+ @run.record(id.to_sym, value)
18
+ end
19
+
20
+ def finish(_state)
21
+ @run
22
+ end
23
+
24
+ def discard_last
25
+ @run.discard_last
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,35 @@
1
+ module EasyFlow
2
+ module Progress
3
+ class Loose
4
+ def initialize(flow, answers, definition = nil)
5
+ @flow = flow
6
+ @answers = answers.to_h.symbolize_keys
7
+ @definition = definition
8
+ end
9
+
10
+ def definition
11
+ @definition || @flow.live_definition
12
+ end
13
+
14
+ def recorded
15
+ @answers
16
+ end
17
+
18
+ def record(id, value)
19
+ @answers = @answers.merge(id.to_sym => value)
20
+ end
21
+
22
+ def finish(state)
23
+ return unless @flow.on_finish?
24
+
25
+ Run.start(@flow).tap { |run| run.update!(recorded: state) }
26
+ end
27
+
28
+ def discard_last
29
+ last = Runner.new(@flow.live_definition).state_on_path(@answers).keys.last
30
+
31
+ @answers = @answers.except(last) if last
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,9 @@
1
+ module EasyFlow
2
+ module Progress
3
+ def self.for(flow, run: nil, answers: {}, definition: nil)
4
+ return Kept.new(run) if run
5
+
6
+ Loose.new(flow, answers, definition)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,13 @@
1
+ module EasyFlow
2
+ class QuestionRunner < Runner
3
+ def question_text(id)
4
+ Steps::Question.asked(step(id)&.config)
5
+ end
6
+
7
+ def choice_label(id, value)
8
+ chosen = Steps::Question.choices_in(step(id)).find { |choice| choice.value == value }
9
+
10
+ chosen&.label.presence || value
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,23 @@
1
+ module EasyFlow
2
+ class Registry
3
+ def initialize
4
+ @step_types = {}
5
+ end
6
+
7
+ def register(step_type)
8
+ @step_types[step_type.id.to_sym] = step_type
9
+ end
10
+
11
+ def fetch(id)
12
+ @step_types.fetch(id.to_sym) { raise UnknownStepType, "no step type registered as #{id}" }
13
+ end
14
+
15
+ def registered?(id)
16
+ id.present? && @step_types.key?(id.to_sym)
17
+ end
18
+
19
+ def step_types
20
+ @step_types.values
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,46 @@
1
+ module EasyFlow
2
+ class Run < ApplicationRecord
3
+ self.table_name = "easy_flow_runs"
4
+
5
+ belongs_to :flow, class_name: "EasyFlow::Definition"
6
+ belongs_to :definition_version, class_name: "EasyFlow::Version"
7
+ belongs_to :owner, polymorphic: true, optional: true
8
+
9
+ def self.start(flow)
10
+ create!(flow: flow, definition_version: flow.live_version)
11
+ end
12
+
13
+ def record(step_id, value)
14
+ update!(recorded: recorded.merge(step_id => value))
15
+ end
16
+
17
+ def recorded
18
+ super.to_h.symbolize_keys
19
+ end
20
+
21
+ def pinned_definition
22
+ definition_version.definition.to_h
23
+ end
24
+
25
+ def next_step(state)
26
+ digest.next_step(state.transform_keys(&:to_s))
27
+ end
28
+
29
+ def walked(state)
30
+ digest.state_on_path(state.transform_keys(&:to_s))
31
+ end
32
+
33
+ def digest
34
+ @digest ||= Digest.new(Document.new(pinned_definition))
35
+ end
36
+
37
+ def discard_last
38
+ last = walked(recorded).keys.map(&:to_sym).last
39
+ update!(recorded: recorded.except(last)) if last
40
+ end
41
+
42
+ def pinned_steps
43
+ Array(definition_version.definition.to_h["nodes"]).index_by { |node| node["id"] }
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,63 @@
1
+ module EasyFlow
2
+ class Runner
3
+ def initialize(document, registry: EasyFlow.registry)
4
+ @document = document.to_h
5
+ @registry = registry
6
+ @digest = Digest.new(Document.new(@document, registry: registry), registry: registry)
7
+ end
8
+
9
+ def slug
10
+ @document["slug"]
11
+ end
12
+
13
+ def headline
14
+ @document["headline"]
15
+ end
16
+
17
+ def steps
18
+ @digest.steps
19
+ end
20
+
21
+ def step(id)
22
+ @digest.step(id.to_s)
23
+ end
24
+
25
+ def next_step(state)
26
+ shown(@digest.next_step(named(state)))
27
+ end
28
+
29
+ def run(progress)
30
+ while (node = @digest.next_step(named(progress.recorded))) && acts?(node)
31
+ progress.record(node.id, @registry.fetch(node.type).process(node, named(progress.recorded)))
32
+ end
33
+ end
34
+
35
+ def drawing_at(state)
36
+ Drawing.of(@digest.next_step(named(state)), @registry)
37
+ end
38
+
39
+ def state_on_path(state)
40
+ @digest.state_on_path(named(state)).symbolize_keys
41
+ end
42
+
43
+ def steps_on_path(state)
44
+ state_on_path(state).keys.map { |id| shown(@digest.step(id.to_s)) }
45
+ end
46
+
47
+ private
48
+
49
+ def acts?(node)
50
+ @registry.registered?(node.type) && @registry.fetch(node.type).acts?
51
+ end
52
+
53
+ def shown(node)
54
+ return node unless node && @registry.registered?(node.type)
55
+
56
+ @registry.fetch(node.type).display_of(node) || node
57
+ end
58
+
59
+ def named(state)
60
+ state.transform_keys(&:to_s)
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,78 @@
1
+ module EasyFlow
2
+ class Settings
3
+ attr_reader :fields, :labels, :record_fields, :record_labels, :choices, :drawn_from, :outputs_of
4
+
5
+ def initialize(fields: {}, labels: {}, record_fields: {}, record_labels: {}, choices: {},
6
+ limits: {}, checks: {}, required: [], drawn_from: {}, outputs_of: {})
7
+ @fields = fields
8
+ @labels = labels
9
+ @record_fields = record_fields
10
+ @record_labels = record_labels
11
+ @choices = choices
12
+ @limits = limits
13
+ @checks = checks
14
+ @required = required
15
+ @drawn_from = drawn_from
16
+ @outputs_of = outputs_of
17
+ end
18
+
19
+ def naming_steps
20
+ fields.select { |_name, type| type == :previous_step }.keys
21
+ end
22
+
23
+ def required
24
+ fields.keys.select { |name| @required.include?(name) || naming_steps.include?(name) }
25
+ end
26
+
27
+ def requirements_for(config)
28
+ naming_steps.map { |name| config.to_h[name.to_s] }.compact_blank
29
+ end
30
+
31
+ def coerce(config)
32
+ config.to_h.to_h { |name, value| [ name, cast(fields[name.to_sym], value, record_fields[name.to_sym]) ] }
33
+ end
34
+
35
+ def objections(config)
36
+ config.to_h.flat_map { |name, value| objections_to(name.to_sym, value) }.compact
37
+ end
38
+
39
+ private
40
+
41
+ def objections_to(name, value)
42
+ [ unoffered(name, value), over_limit(name, value), refused(name, value) ]
43
+ end
44
+
45
+ def unoffered(name, value)
46
+ offered = choices[name]
47
+ return unless offered
48
+
49
+ stray = Array(value).find { |chosen| offered.exclude?(chosen) }
50
+ "#{labels[name]} does not offer #{stray}" if stray
51
+ end
52
+
53
+ def over_limit(name, value)
54
+ allowed = @limits[name]
55
+ return unless allowed && Array(value).size > allowed
56
+
57
+ "#{labels[name]} takes at most #{allowed}"
58
+ end
59
+
60
+ def refused(name, value)
61
+ @checks[name]&.call(value)
62
+ end
63
+
64
+ def cast(type, value, holds = nil)
65
+ case type
66
+ when :integer then Integer(value, exception: false)
67
+ when :float then Float(value, exception: false)
68
+ when :boolean then ActiveModel::Type::Boolean.new.cast(value).present?
69
+ when :list then Array(value).map { |entry| cast_entry(entry, holds.to_h) }
70
+ else value
71
+ end
72
+ end
73
+
74
+ def cast_entry(entry, holds)
75
+ entry.to_h.to_h { |name, value| [ name, cast(holds[name.to_sym], value) ] }
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,11 @@
1
+ module EasyFlow
2
+ class Start
3
+ include Step
4
+
5
+ step_name "Start"
6
+
7
+ begins_here
8
+
9
+ names_by { |_node| "Start" }
10
+ end
11
+ end
@@ -0,0 +1,40 @@
1
+ module EasyFlow
2
+ module Step
3
+ extend ActiveSupport::Concern
4
+
5
+ WORDS = (StepType::Declaration.public_instance_methods(false) - [ :to_step_type ]).freeze
6
+ BEHAVIOURS = %i[route process].freeze
7
+
8
+ included do
9
+ class_attribute :declarations, default: []
10
+ end
11
+
12
+ class_methods do
13
+ WORDS.each do |word|
14
+ define_method(word) do |*arguments, **options, &block|
15
+ self.declarations += [ [ word, arguments, options, block ] ]
16
+ end
17
+ end
18
+
19
+ def step_type
20
+ spoken = declarations + behaviours
21
+ StepType.define(step_type_id) do
22
+ spoken.each { |word, arguments, options, block| public_send(word, *arguments, **options, &block) }
23
+ end
24
+ end
25
+
26
+ def behaviours
27
+ BEHAVIOURS.select { |word| method_defined?(word) }
28
+ .map { |word| [ word, [], {}, ->(node, state) { new.public_send(word, node, state) } ] }
29
+ end
30
+
31
+ def register(registry = EasyFlow.registry)
32
+ registry.register(step_type)
33
+ end
34
+
35
+ def step_type_id
36
+ name.demodulize.underscore.to_sym
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,116 @@
1
+ module EasyFlow
2
+ class StepType
3
+ class Declaration
4
+ def initialize(id)
5
+ @id = id
6
+ @step_name = id.to_s
7
+ @fields = {}
8
+ @labels = {}
9
+ @record_fields = {}
10
+ @record_labels = {}
11
+ @choices = {}
12
+ @limits = {}
13
+ @checks = {}
14
+ @drawn_from = {}
15
+ @outputs_of = {}
16
+ @declared_outputs = []
17
+ @required = []
18
+ @awaits_input = false
19
+ @ends_here = false
20
+ @begins_here = false
21
+ end
22
+
23
+ def output(name, type: :string, label: nil, values: nil, from: nil)
24
+ @declared_outputs += [ Output.new(name: name, type: type, label: label, values: values, from: from) ]
25
+ end
26
+
27
+ def displays_by(&display)
28
+ @display = display
29
+ end
30
+
31
+ def drawn_by(template)
32
+ @drawn_by = template
33
+ end
34
+
35
+ def process(&behaviour)
36
+ @behaviour = behaviour
37
+ end
38
+
39
+ def route(&routing)
40
+ @routing = routing
41
+ end
42
+
43
+ def step_name(value)
44
+ @step_name = value
45
+ end
46
+
47
+ def awaits_input
48
+ @awaits_input = true
49
+ end
50
+
51
+ def ends_here
52
+ @ends_here = true
53
+ end
54
+
55
+ def begins_here
56
+ @begins_here = true
57
+ end
58
+
59
+ def names_by(field = nil, &naming)
60
+ @naming_field = field
61
+ @naming = naming
62
+ end
63
+
64
+ def setting(name, type: nil, from: nil, outputs_of: nil, label: nil, options: nil, limit: nil, check: nil, required: false, &entries)
65
+ type ||= :from_step if from
66
+ type ||= :from_step if outputs_of
67
+ raise UnknownFieldType, "#{type} is not one of #{FIELD_TYPES.join(', ')}" unless FIELD_TYPES.include?(type)
68
+
69
+ @drawn_from[name] = from if from
70
+ @outputs_of[name] = outputs_of if outputs_of
71
+ @required += [ name ] if required
72
+
73
+ declare_entries(name, entries) if type == :list
74
+ declare_choices(name, type, options)
75
+ @limits[name] = limit if limit
76
+ @checks[name] = check if check
77
+ @labels[name] = label.presence || name.to_s.humanize
78
+ @fields[name] = type
79
+ end
80
+
81
+ protected
82
+
83
+ attr_reader :fields, :labels
84
+
85
+ private
86
+
87
+ def declare_choices(name, type, options)
88
+ return @choices[name] = options if options.present?
89
+ raise UnknownFieldType, "a #{type} must offer options" if %i[select multi_select].include?(type)
90
+ end
91
+
92
+ def declare_entries(name, entries)
93
+ raise UnknownFieldType, "a list must say what an entry holds" if entries.nil?
94
+
95
+ declared = Declaration.new(:entry).tap { |entry| entry.instance_eval(&entries) }
96
+ @record_fields[name] = declared.fields
97
+ @record_labels[name] = declared.labels
98
+ end
99
+
100
+ public
101
+
102
+ def to_step_type
103
+ StepType.new(id: @id, step_name: @step_name, settings: settings, awaits_input: @awaits_input,
104
+ ends_here: @ends_here, begins_here: @begins_here, behaviour: @behaviour, routing: @routing,
105
+ display: @display, drawn_by: @drawn_by, naming_field: @naming_field, naming: @naming,
106
+ outputs: @declared_outputs)
107
+ end
108
+
109
+ def settings
110
+ Settings.new(fields: @fields, labels: @labels, record_fields: @record_fields,
111
+ record_labels: @record_labels, choices: @choices, limits: @limits, checks: @checks,
112
+ required: @required, drawn_from: @drawn_from, outputs_of: @outputs_of)
113
+ end
114
+ end
115
+ end
116
+ end