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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +2 -0
- data/Rakefile +6 -0
- data/app/models/easy_flow/admission.rb +17 -0
- data/app/models/easy_flow/application_record.rb +5 -0
- data/app/models/easy_flow/asked.rb +3 -0
- data/app/models/easy_flow/canvas.rb +165 -0
- data/app/models/easy_flow/change.rb +16 -0
- data/app/models/easy_flow/choice.rb +7 -0
- data/app/models/easy_flow/condition.rb +22 -0
- data/app/models/easy_flow/definition.rb +125 -0
- data/app/models/easy_flow/digest.rb +114 -0
- data/app/models/easy_flow/document.rb +129 -0
- data/app/models/easy_flow/drawing.rb +13 -0
- data/app/models/easy_flow/edge.rb +7 -0
- data/app/models/easy_flow/edit_history.rb +73 -0
- data/app/models/easy_flow/layout.rb +86 -0
- data/app/models/easy_flow/name.rb +13 -0
- data/app/models/easy_flow/node.rb +3 -0
- data/app/models/easy_flow/output.rb +21 -0
- data/app/models/easy_flow/progress/kept.rb +29 -0
- data/app/models/easy_flow/progress/loose.rb +35 -0
- data/app/models/easy_flow/progress.rb +9 -0
- data/app/models/easy_flow/question_runner.rb +13 -0
- data/app/models/easy_flow/registry.rb +23 -0
- data/app/models/easy_flow/run.rb +46 -0
- data/app/models/easy_flow/runner.rb +63 -0
- data/app/models/easy_flow/settings.rb +78 -0
- data/app/models/easy_flow/start.rb +11 -0
- data/app/models/easy_flow/step.rb +40 -0
- data/app/models/easy_flow/step_type/declaration.rb +116 -0
- data/app/models/easy_flow/step_type.rb +67 -0
- data/app/models/easy_flow/steps/question.rb +52 -0
- data/app/models/easy_flow/switch.rb +15 -0
- data/app/models/easy_flow/terminal.rb +11 -0
- data/app/models/easy_flow/validator.rb +169 -0
- data/app/models/easy_flow/version.rb +24 -0
- data/app/models/easy_flow/violation.rb +7 -0
- data/config/routes.rb +2 -0
- data/db/migrate/20260924120000_create_easy_flow_definitions.rb +20 -0
- data/db/migrate/20260924120100_create_easy_flow_versions.rb +16 -0
- data/db/migrate/20260924120200_create_easy_flow_runs.rb +13 -0
- data/lib/easy_flow/engine.rb +20 -0
- data/lib/easy_flow/version.rb +3 -0
- data/lib/easy_flow.rb +50 -0
- 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
data/Rakefile
ADDED
|
@@ -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,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,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
|