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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6e3c51d7ce51a8a5b5d9f1209d8b621a21353082b1815cd3d5b567c7da7d2acc
4
+ data.tar.gz: 36fb74833ec6d3ee98e20c2d9fb5328a14cd160727f0854f3384fb6dee4e0559
5
+ SHA512:
6
+ metadata.gz: 6c8720e19f2fa889ccaa3c2ddc8915d16951edf4620165009c5a32912d3d30199c0ec516cea28cca106028484b76cf8f648866dc30ea3c6258d1c0e36927738d
7
+ data.tar.gz: 5c12ab8931cd2ec76567c9ae5667849b88a64454664dd0dfb40d97ab21c583502edf3e2d9010011836c8de363eaef6d94236d52dd60fa078961c53420701b67a
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright tylercschneider
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,2 @@
1
+ # easy_flow
2
+ Guided, versioned, branching flows for Rails apps
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
@@ -0,0 +1,17 @@
1
+ module EasyFlow
2
+ module Admission
3
+ def self.of(flow, permitted:)
4
+ raise NotPublished if flow.nil? || flow.live_definition.blank?
5
+ raise NotPermitted unless permitted && flow.available?
6
+
7
+ flow
8
+ end
9
+
10
+ def self.of_run(run, permitted: true)
11
+ raise Withdrawn if run.definition_version.withdrawn?
12
+ raise NotPermitted unless permitted && run.flow.available?
13
+
14
+ run
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,5 @@
1
+ module EasyFlow
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,3 @@
1
+ module EasyFlow
2
+ Asked = Data.define(:id, :text, :choices)
3
+ end
@@ -0,0 +1,165 @@
1
+ module EasyFlow
2
+ class Canvas
3
+ def initialize(document, registry: EasyFlow.registry)
4
+ @document = document
5
+ @registry = registry
6
+ end
7
+
8
+ def to_h
9
+ { "nodes" => nodes, "edges" => edges, "palette" => palette, "violations" => violations }
10
+ end
11
+
12
+ private
13
+
14
+ def nodes
15
+ @document.nodes.map { |node| drawn_step(node) } + waiting.map { |gap| drawn_placeholder(gap) }
16
+ end
17
+
18
+ def drawn_step(node)
19
+ { "id" => node.id, "type" => node.type, "label" => label_for(node),
20
+ "config" => node.config, "ports" => [], "ends_here" => ends_here?(node), "begins_here" => begins_here?(node),
21
+ "loose" => loose?(node), "choices" => choices_for(node), "placeholder" => false, **placed[node.id] }
22
+ end
23
+
24
+ def drawn_placeholder(gap)
25
+ { "id" => gap[:id], "type" => "placeholder", "label" => gap[:on], "config" => {}, "ports" => [],
26
+ "ends_here" => false, "begins_here" => false, "loose" => false, "choices" => {},
27
+ "placeholder" => true, "from" => gap[:from], "on" => gap[:on], **placed[gap[:id]] }
28
+ end
29
+
30
+ def edges
31
+ drawn.edges.each_with_index.map do |edge, index|
32
+ { "id" => "#{edge.from}-#{edge.to}-#{index}", "source" => edge.from, "target" => edge.to, "label" => edge.on&.to_s,
33
+ "placeholder" => waiting.any? { |gap| gap[:id] == edge.to } }.merge(routing(edge, placed))
34
+ end
35
+ end
36
+
37
+ def placed
38
+ @placed ||= Layout.new(drawn).positions
39
+ end
40
+
41
+ def drawn
42
+ @drawn ||= Document.new(@document.to_h.merge(
43
+ "nodes" => Array(@document.to_h["nodes"]) + waiting.map { |gap| { "id" => gap[:id], "type" => "placeholder" } },
44
+ "edges" => Array(@document.to_h["edges"]) + waiting.map { |gap| { "from" => gap[:from], "to" => gap[:id], "on" => gap[:on] } }
45
+ ), registry: @registry)
46
+ end
47
+
48
+ def waiting
49
+ @waiting ||= @document.nodes.flat_map { |node| gaps_after(node) }
50
+ end
51
+
52
+ def gaps_after(node)
53
+ wired = @document.edges_from(node.id).map { |edge| edge.on.to_s }
54
+ results = digest.routing_values(node.id)
55
+ return [ { id: "#{node.id}--", from: node.id, on: nil } ] if results.empty? && leads_nowhere?(node)
56
+
57
+ results.reject { |value| wired.include?(value) }
58
+ .map { |value| { id: "#{node.id}--#{value}", from: node.id, on: value } }
59
+ end
60
+
61
+ def leads_nowhere?(node)
62
+ !ends_here?(node) && !loose?(node) && @document.edges_from(node.id).none?
63
+ end
64
+
65
+ def routing(edge, placed)
66
+ from = placed[edge.from]
67
+ to = placed[edge.to]
68
+ return { "leaves" => "bottom", "enters" => "top", "route" => "straight" } unless from && to
69
+
70
+ return alongside_routing(from, to) unless to["row"] > from["row"]
71
+
72
+ { "leaves" => "bottom", "enters" => "top",
73
+ "route" => to["column"] == from["column"] ? "straight" : "lane" }
74
+ end
75
+
76
+ def alongside_routing(from, to)
77
+ leftward = to["column"] < from["column"]
78
+
79
+ { "leaves" => leftward ? "left" : "right", "enters" => leftward ? "right" : "left",
80
+ "route" => to["row"] == from["row"] ? "straight" : "detour" }
81
+ end
82
+
83
+ def palette
84
+ @registry.step_types.reject(&:begins_here?).map do |step_type|
85
+ { "type" => step_type.id.to_s, "label" => step_type.step_name,
86
+ "fields" => step_type.settings.fields.transform_keys(&:to_s).transform_values(&:to_s),
87
+ "labels" => step_type.settings.labels.transform_keys(&:to_s),
88
+ "choices" => step_type.settings.choices.transform_keys(&:to_s),
89
+ "records" => holdings_of(step_type),
90
+ "record_labels" => step_type.settings.record_labels.to_h { |name, held| [ name.to_s, held.transform_keys(&:to_s) ] },
91
+ "awaits_input" => step_type.awaits_input? }
92
+ end
93
+ end
94
+
95
+ def holdings_of(step_type)
96
+ step_type.settings.record_fields.to_h do |name, holds|
97
+ [ name.to_s, holds.transform_keys(&:to_s).transform_values(&:to_s) ]
98
+ end
99
+ end
100
+
101
+ def violations
102
+ Validator.new(@document, registry: @registry).violations.map do |violation|
103
+ { "node" => violation.node, "problem" => violation.problem.to_s, "detail" => violation.detail }
104
+ end
105
+ end
106
+
107
+ def label_for(node)
108
+ Name.of(node, @registry)
109
+ end
110
+
111
+ def choices_for(node)
112
+ naming_steps(node).index_with { earlier_than(node) }.merge(drawn_by(node)).merge(named_outputs_by(node))
113
+ end
114
+
115
+ def named_outputs_by(node)
116
+ step_type_for(node)&.settings&.outputs_of.to_h.to_h do |name, source|
117
+ [ name.to_s, digest.outputs_of(node.config[source.to_s]) ]
118
+ end
119
+ end
120
+
121
+ def drawn_by(node)
122
+ step_type_for(node)&.settings&.drawn_from.to_h.to_h do |name, source|
123
+ [ name.to_s, digest.values_of(step_named_by(node, source), node.config[source.to_s]) ]
124
+ end
125
+ end
126
+
127
+ def step_named_by(node, source)
128
+ node.config[step_type_for(node).settings.outputs_of[source].to_s]
129
+ end
130
+
131
+ def digest
132
+ Digest.new(@document, registry: @registry)
133
+ end
134
+
135
+ def naming_steps(node)
136
+ step_type_for(node)&.settings&.naming_steps.to_a.map(&:to_s)
137
+ end
138
+
139
+ def earlier_than(node)
140
+ digest.preceding(node.id).map do |id|
141
+ { "value" => id, "label" => label_for(@document.node(id)) }
142
+ end
143
+ end
144
+
145
+ def ends_here?(node)
146
+ step_type_for(node)&.ends_here? || false
147
+ end
148
+
149
+ def loose?(node)
150
+ @document.entry.present? && @document.reachable.exclude?(node.id)
151
+ end
152
+
153
+ def begins_here?(node)
154
+ step_type_for(node)&.begins_here? || false
155
+ end
156
+
157
+ def ports_for(node)
158
+ digest.routing_values(node.id)
159
+ end
160
+
161
+ def step_type_for(node)
162
+ @registry.fetch(node.type) if @registry.registered?(node.type)
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,16 @@
1
+ module EasyFlow
2
+ module Change
3
+ PHRASING = { "added" => "Added", "updated" => "Updated", "removed" => "Removed",
4
+ "moved" => "Moved", "connected" => "Connected", "disconnected" => "Disconnected", "returned" => "Returned to" }.freeze
5
+
6
+ def self.phrase(change)
7
+ named = Array(change["named"]).compact_blank.map { |name| "“#{name}”" }
8
+ verb = PHRASING.fetch(change["action"], change["action"])
9
+
10
+ return "#{verb} #{named.join(' → ')}" if named.any?
11
+ return "#{verb} #{change['detail']}" if change["detail"].present?
12
+
13
+ verb
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,7 @@
1
+ module EasyFlow
2
+ Choice = Data.define(:value, :label, :hint) do
3
+ def initialize(value:, label: nil, hint: nil)
4
+ super(value: value, label: label.presence || value, hint: hint)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,22 @@
1
+ module EasyFlow
2
+ class Condition
3
+ include Step
4
+
5
+ step_name "Condition"
6
+
7
+ setting :step, type: :previous_step
8
+ setting :output, outputs_of: :step, required: true
9
+ setting :comparison, type: :select, options: [ "is", "is not" ], required: true
10
+ setting :answer, from: :output, required: true
11
+
12
+ output :result, type: :boolean, values: [ true, false ]
13
+
14
+ names_by { |node| node.config.values_at("step", "comparison", "answer").compact_blank.join(" ").presence }
15
+
16
+ def route(node, state)
17
+ matched = state[node.config["step"]] == node.config["answer"]
18
+
19
+ node.config["comparison"] == "is not" ? !matched : matched
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,125 @@
1
+ module EasyFlow
2
+ class Definition < ApplicationRecord
3
+ self.table_name = "easy_flow_definitions"
4
+
5
+ enum :status, { active: "active", hidden: "hidden", inactive: "inactive" }
6
+ enum :kind, { scored: "scored", guide: "guide" }
7
+ enum :persists, { unsaved: "unsaved", each_step: "each_step", on_finish: "on_finish" }
8
+
9
+ validates :slug, presence: true
10
+
11
+ after_initialize :begin_the_flow, if: :new_record?
12
+
13
+ scope :listable, -> { active }
14
+
15
+ # Declared before :definition_versions so runs clear first, otherwise
16
+ # destroying a flow trips the runs -> definition_versions FK.
17
+ has_many :runs, class_name: "EasyFlow::Run", foreign_key: :flow_id, dependent: :destroy
18
+ has_many :definition_versions, class_name: "EasyFlow::Version", foreign_key: :flow_id, dependent: :destroy
19
+
20
+ def self.upsert_definition(definition)
21
+ find_or_initialize_by(slug: definition["slug"]).tap do |flow|
22
+ flow.save!
23
+ flow.record_definition(definition) unless flow.definition == definition
24
+ end
25
+ end
26
+
27
+ def definition
28
+ current_definition_version&.definition
29
+ end
30
+
31
+ def current_definition_version
32
+ definition_versions.find_by(number: cursor)
33
+ end
34
+
35
+ def edit_history
36
+ @edit_history ||= EditHistory.new(self)
37
+ end
38
+
39
+ def publish
40
+ create_version
41
+
42
+ publish_version(current_definition_version)
43
+ end
44
+
45
+ def return_to(version)
46
+ raise ActiveRecord::RecordNotFound unless definition_versions.exists?(version.id)
47
+ raise OutOfService if version.out_of_service?
48
+
49
+ update!(document: version.definition, undone_changes: [],
50
+ changes_since_version: changes_since_version.to_a + [ returning_to(version) ])
51
+ end
52
+
53
+ def create_version
54
+ record_definition(document, changes_since_version.to_a) unless versioned?
55
+
56
+ update!(undo_history: edit_history.undoable, changes_since_version: [], undone_changes: [])
57
+ end
58
+
59
+ def returning_to(version)
60
+ { "action" => "returned", "steps" => [], "named" => [],
61
+ "detail" => "version #{version.number}", "before" => document }
62
+ end
63
+
64
+ def versioned?
65
+ document == definition
66
+ end
67
+
68
+ def record_definition(payload, captured = [])
69
+ definition_versions.create!(number: next_definition_number, definition: payload,
70
+ changes_captured: captured.map { |change| change.except("before") })
71
+ .tap { |version| update!(definition_cursor: version.number, document: payload) }
72
+ end
73
+
74
+ def publish_version(version)
75
+ raise OutOfService if version.out_of_service?
76
+ return if live_version == version
77
+
78
+ live_version&.update!(status: :superseded)
79
+ version.update!(status: :live)
80
+ end
81
+
82
+ def available?
83
+ !inactive?
84
+ end
85
+
86
+ def retire_version(version)
87
+ version.update!(status: :retired)
88
+ end
89
+
90
+ def withdraw_version(version)
91
+ version.update!(status: :withdrawn)
92
+ end
93
+
94
+ def live_version
95
+ definition_versions.find_by(status: :live)
96
+ end
97
+
98
+ def live_definition
99
+ live_version&.definition
100
+ end
101
+
102
+ def runner
103
+ Runner.new(live_definition)
104
+ end
105
+
106
+ private
107
+
108
+ def begin_the_flow
109
+ self.document ||= { "nodes" => [ { "id" => "start", "type" => "start" }, { "id" => "end", "type" => "terminal" } ],
110
+ "edges" => [ { "from" => "start", "to" => "end" } ] }
111
+ end
112
+
113
+ def cursor
114
+ definition_cursor || recorded_numbers.max
115
+ end
116
+
117
+ def recorded_numbers
118
+ definition_versions.pluck(:number)
119
+ end
120
+
121
+ def next_definition_number
122
+ (definition_versions.maximum(:number) || 0) + 1
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,114 @@
1
+ module EasyFlow
2
+ class Digest
3
+ def initialize(document, registry: EasyFlow.registry)
4
+ @document = document
5
+ @registry = registry
6
+ end
7
+
8
+ def entry
9
+ @document.node(@document.entry)
10
+ end
11
+
12
+ def step(id)
13
+ @document.node(id)
14
+ end
15
+
16
+ def steps
17
+ @document.nodes
18
+ end
19
+
20
+ def preceding(id)
21
+ return [] unless @document.reachable.include?(id)
22
+
23
+ @document.nodes.map(&:id).uniq
24
+ .reject { |other| other == id || @document.reachable(without: other).include?(id) }
25
+ end
26
+
27
+ def routing_values(id)
28
+ named = step(id)
29
+ return [] unless named && step_type(named)&.routes?
30
+
31
+ step_type(named).outputs.flat_map { |output| values_taken(output, named) }.map { |value| value["value"].to_s }
32
+ end
33
+
34
+ def values_taken(output, node)
35
+ return output.values_for(node) unless output.from
36
+
37
+ values_out_of(node.config[output.from.to_s])
38
+ end
39
+
40
+ def values_of(id, output_name)
41
+ named = step(id)
42
+ return [] unless named && output_name.present?
43
+
44
+ step_type(named)&.values_of(output_name, named).to_a
45
+ end
46
+
47
+ def outputs_of(id)
48
+ named = step(id)
49
+ return [] unless named
50
+
51
+ step_type(named)&.outputs.to_a.map { |output| { "value" => output.name.to_s, "label" => output.label } }
52
+ end
53
+
54
+ def values_out_of(id)
55
+ named = step(id)
56
+ return [] unless named
57
+
58
+ step_type(named)&.outputs.to_a.flat_map { |output| output.values_for(named) }
59
+ end
60
+
61
+ def requirements(id)
62
+ node = step(id)
63
+ step_type(node)&.settings&.requirements_for(node.config).to_a
64
+ end
65
+
66
+ def next_step(state)
67
+ walk(state).last
68
+ end
69
+
70
+ def state_on_path(state)
71
+ state.slice(*walk(state).first)
72
+ end
73
+
74
+ private
75
+
76
+ def walk(state)
77
+ recorded = []
78
+ visited = []
79
+ cursor = entry
80
+
81
+ while cursor && !visited.include?(cursor.id)
82
+ return [ recorded, cursor ] if pending?(cursor, state)
83
+
84
+ recorded << cursor.id if state.key?(cursor.id)
85
+ visited << cursor.id
86
+ cursor = successor(cursor, state)
87
+ end
88
+
89
+ [ recorded, nil ]
90
+ end
91
+
92
+ def pending?(node, state)
93
+ return false if state.key?(node.id)
94
+
95
+ step_type(node)&.awaits_input? || acts?(node)
96
+ end
97
+
98
+ def acts?(node)
99
+ step_type(node)&.acts? || false
100
+ end
101
+
102
+ def successor(node, state)
103
+ leaving = @document.edges_from(node.id)
104
+ port = step_type(node)&.route(node, state)
105
+ taken = port.nil? ? leaving.first : leaving.find { |edge| edge.on.to_s == port.to_s }
106
+
107
+ taken && @document.node(taken.to)
108
+ end
109
+
110
+ def step_type(node)
111
+ @registry.fetch(node.type) if node && @registry.registered?(node.type)
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,129 @@
1
+ module EasyFlow
2
+ class Document
3
+ def initialize(document, registry: EasyFlow.registry)
4
+ @document = document
5
+ @registry = registry
6
+ end
7
+
8
+ def nodes
9
+ Array(@document["nodes"]).map { |node| Node.new(id: node["id"], type: node["type"], config: node.except("id", "type")) }
10
+ end
11
+
12
+ def edges
13
+ Array(@document["edges"]).map { |edge| Edge.new(from: edge["from"], to: edge["to"], on: edge["on"]) }
14
+ end
15
+
16
+ def entry
17
+ beginnings.first
18
+ end
19
+
20
+ def beginnings
21
+ nodes.select { |node| begins_here?(node) }.map(&:id)
22
+ end
23
+
24
+ def begins_here?(node)
25
+ @registry.registered?(node.type) && @registry.fetch(node.type).begins_here?
26
+ end
27
+
28
+ def node(id)
29
+ nodes.find { |node| node.id == id }
30
+ end
31
+
32
+ def edges_from(id)
33
+ edges.select { |edge| edge.from == id }
34
+ end
35
+
36
+ def reachable(without: nil)
37
+ found = []
38
+ frontier = [ entry ]
39
+
40
+ while (id = frontier.shift)
41
+ next if found.include?(id) || id == without
42
+
43
+ found << id
44
+ frontier.concat(edges_from(id).map(&:to))
45
+ end
46
+
47
+ found
48
+ end
49
+
50
+ def to_h
51
+ @document
52
+ end
53
+
54
+ def insert(node, on:, leaving: nil)
55
+ source, target = on
56
+ replaced = raw_edges.find { |edge| edge["from"] == source && edge["to"] == target }
57
+ bridged = [ { "from" => source, "to" => node["id"], "on" => replaced&.fetch("on", nil) }.compact,
58
+ { "from" => node["id"], "to" => target, "on" => leaving }.compact ]
59
+
60
+ with(nodes: raw_nodes + [ node ], edges: (raw_edges - [ replaced ]) + bridged)
61
+ end
62
+
63
+ def add(node)
64
+ with(nodes: raw_nodes + [ node ], edges: raw_edges)
65
+ end
66
+
67
+ def connect(from:, to:, on: nil)
68
+ with(nodes: raw_nodes, edges: raw_edges + [ { "from" => from, "to" => to, "on" => on }.compact ])
69
+ end
70
+
71
+ def disconnect(from:, to:)
72
+ with(nodes: raw_nodes, edges: raw_edges.reject { |edge| edge["from"] == from && edge["to"] == to })
73
+ end
74
+
75
+ def move(id, on:, leaving: nil)
76
+ step = raw_nodes.find { |node| node["id"] == id }
77
+ return self unless step
78
+
79
+ remove(id).insert(step, on: on, leaving: leaving)
80
+ end
81
+
82
+ def configure(id, config)
83
+ reconfigured = raw_nodes.map do |node|
84
+ node["id"] == id ? node.slice("id", "type").merge(config) : node
85
+ end
86
+
87
+ with(nodes: reconfigured, edges: raw_edges)
88
+ end
89
+
90
+ def rewire(from:, to:, target:)
91
+ rewritten = raw_edges.map do |edge|
92
+ edge["from"] == from && edge["to"] == to ? edge.merge("to" => target) : edge
93
+ end
94
+
95
+ with(nodes: raw_nodes, edges: rewritten)
96
+ end
97
+
98
+ def remove(id)
99
+ incoming = raw_edges.select { |edge| edge["to"] == id }
100
+ outgoing = raw_edges.select { |edge| edge["from"] == id }
101
+
102
+ with(nodes: raw_nodes.reject { |node| node["id"] == id },
103
+ edges: (raw_edges - incoming - outgoing) + bridges(incoming, outgoing))
104
+ end
105
+
106
+ private
107
+
108
+ def bridges(incoming, outgoing)
109
+ incoming.product(outgoing).map do |arriving, leaving|
110
+ { "from" => arriving["from"], "to" => leaving["to"], "on" => arriving["on"] }.compact
111
+ end
112
+ end
113
+
114
+ def raw_nodes
115
+ Array(@document["nodes"])
116
+ end
117
+
118
+ def raw_edges
119
+ Array(@document["edges"])
120
+ end
121
+
122
+ def with(nodes:, edges:)
123
+ Document.new(@document.merge("nodes" => nodes, "edges" => edges), registry: @registry).tap do |edited|
124
+ broken = Validator.new(edited, registry: @registry).malformations
125
+ raise InvalidEdit, broken.map { |violation| "#{violation.node}: #{violation.problem}" }.join(", ") if broken.any?
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,13 @@
1
+ module EasyFlow
2
+ module Drawing
3
+ def self.of(node, registry = EasyFlow.registry)
4
+ named(node, registry).presence || EasyFlow.drawing
5
+ end
6
+
7
+ def self.named(node, registry)
8
+ return unless node && registry.registered?(node.type)
9
+
10
+ registry.fetch(node.type).drawn_by
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,7 @@
1
+ module EasyFlow
2
+ Edge = Data.define(:from, :to, :on) do
3
+ def initialize(from:, to:, on: nil)
4
+ super
5
+ end
6
+ end
7
+ end