alembic 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 (168) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +105 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/builds/alembic/canvas.js +1 -0
  6. data/app/assets/builds/alembic/page_builder.css +1 -0
  7. data/app/assets/builds/alembic/page_builder.js +5 -0
  8. data/app/assets/stylesheets/alembic/application.css +15 -0
  9. data/app/controllers/alembic/application_controller.rb +32 -0
  10. data/app/controllers/alembic/flows_controller.rb +104 -0
  11. data/app/controllers/alembic/manage/base_controller.rb +16 -0
  12. data/app/controllers/alembic/manage/canvas_controller.rb +183 -0
  13. data/app/controllers/alembic/manage/definitions_controller.rb +15 -0
  14. data/app/controllers/alembic/manage/flows_controller.rb +55 -0
  15. data/app/controllers/alembic/manage/page_blocks_controller.rb +19 -0
  16. data/app/controllers/alembic/manage/pages_controller.rb +38 -0
  17. data/app/controllers/alembic/manage/previews_controller.rb +38 -0
  18. data/app/controllers/alembic/manage/versions_controller.rb +22 -0
  19. data/app/controllers/concerns/alembic/manage/authenticates_admin.rb +19 -0
  20. data/app/controllers/concerns/alembic/manage/draws_canvas.rb +35 -0
  21. data/app/helpers/alembic/application_helper.rb +11 -0
  22. data/app/javascript/alembic/canvas/Canvas.jsx +197 -0
  23. data/app/javascript/alembic/canvas/Connector.jsx +30 -0
  24. data/app/javascript/alembic/canvas/ConnectorLayer.jsx +31 -0
  25. data/app/javascript/alembic/canvas/Control.jsx +42 -0
  26. data/app/javascript/alembic/canvas/Inspector.jsx +44 -0
  27. data/app/javascript/alembic/canvas/Panel.jsx +67 -0
  28. data/app/javascript/alembic/canvas/Placeholder.jsx +24 -0
  29. data/app/javascript/alembic/canvas/Port.jsx +16 -0
  30. data/app/javascript/alembic/canvas/Records.jsx +34 -0
  31. data/app/javascript/alembic/canvas/StepCard.jsx +57 -0
  32. data/app/javascript/alembic/canvas/Toolbar.jsx +19 -0
  33. data/app/javascript/alembic/canvas/TypePicker.jsx +21 -0
  34. data/app/javascript/alembic/canvas/changes.js +5 -0
  35. data/app/javascript/alembic/canvas/choices.js +4 -0
  36. data/app/javascript/alembic/canvas/flow.js +7 -0
  37. data/app/javascript/alembic/canvas/flowSender.js +22 -0
  38. data/app/javascript/alembic/canvas/ids.js +7 -0
  39. data/app/javascript/alembic/canvas/rows.js +2 -0
  40. data/app/javascript/alembic/canvas/styles.js +3 -0
  41. data/app/javascript/alembic/canvas/useConnectors.js +75 -0
  42. data/app/javascript/alembic/canvas/useFlow.js +17 -0
  43. data/app/javascript/alembic/canvas.jsx +13 -0
  44. data/app/javascript/alembic/page_builder/PageBuilder.jsx +18 -0
  45. data/app/javascript/alembic/page_builder.jsx +10 -0
  46. data/app/jobs/alembic/application_job.rb +4 -0
  47. data/app/mailers/alembic/application_mailer.rb +6 -0
  48. data/app/models/alembic/application_record.rb +5 -0
  49. data/app/models/alembic/asked.rb +3 -0
  50. data/app/models/alembic/choice.rb +7 -0
  51. data/app/models/alembic/flow/admission.rb +19 -0
  52. data/app/models/alembic/flow/canvas.rb +167 -0
  53. data/app/models/alembic/flow/change.rb +18 -0
  54. data/app/models/alembic/flow/condition.rb +24 -0
  55. data/app/models/alembic/flow/definition.rb +132 -0
  56. data/app/models/alembic/flow/digest.rb +116 -0
  57. data/app/models/alembic/flow/document.rb +131 -0
  58. data/app/models/alembic/flow/drawing.rb +15 -0
  59. data/app/models/alembic/flow/edge.rb +9 -0
  60. data/app/models/alembic/flow/edit_history.rb +75 -0
  61. data/app/models/alembic/flow/layout.rb +88 -0
  62. data/app/models/alembic/flow/name.rb +15 -0
  63. data/app/models/alembic/flow/node.rb +5 -0
  64. data/app/models/alembic/flow/output.rb +23 -0
  65. data/app/models/alembic/flow/progress/kept.rb +38 -0
  66. data/app/models/alembic/flow/progress/loose.rb +43 -0
  67. data/app/models/alembic/flow/progress.rb +11 -0
  68. data/app/models/alembic/flow/registry.rb +25 -0
  69. data/app/models/alembic/flow/run.rb +54 -0
  70. data/app/models/alembic/flow/runner.rb +65 -0
  71. data/app/models/alembic/flow/settings.rb +80 -0
  72. data/app/models/alembic/flow/start.rb +13 -0
  73. data/app/models/alembic/flow/step.rb +42 -0
  74. data/app/models/alembic/flow/step_type/declaration.rb +118 -0
  75. data/app/models/alembic/flow/step_type.rb +69 -0
  76. data/app/models/alembic/flow/summaries.rb +44 -0
  77. data/app/models/alembic/flow/summary_version.rb +13 -0
  78. data/app/models/alembic/flow/switch.rb +17 -0
  79. data/app/models/alembic/flow/terminal.rb +13 -0
  80. data/app/models/alembic/flow/validator.rb +171 -0
  81. data/app/models/alembic/flow/version.rb +26 -0
  82. data/app/models/alembic/flow/violation.rb +9 -0
  83. data/app/models/alembic/flow.rb +44 -0
  84. data/app/models/alembic/outputs/band.rb +22 -0
  85. data/app/models/alembic/outputs/grouped.rb +26 -0
  86. data/app/models/alembic/outputs/lowest.rb +20 -0
  87. data/app/models/alembic/outputs/percentage.rb +31 -0
  88. data/app/models/alembic/outputs/tally.rb +23 -0
  89. data/app/models/alembic/outputs/weighted_sum.rb +26 -0
  90. data/app/models/alembic/page.rb +11 -0
  91. data/app/models/alembic/runner.rb +13 -0
  92. data/app/models/alembic/steps/question.rb +52 -0
  93. data/app/models/alembic/summary/output_type.rb +40 -0
  94. data/app/models/alembic/summary/registry.rb +25 -0
  95. data/app/models/alembic/summary/report.rb +31 -0
  96. data/app/models/alembic/summary/result.rb +5 -0
  97. data/app/models/alembic/summary/run.rb +13 -0
  98. data/app/models/alembic/summary.rb +15 -0
  99. data/app/views/alembic/flow/steps/_choosing.html.erb +9 -0
  100. data/app/views/alembic/flows/complete.html.erb +34 -0
  101. data/app/views/alembic/flows/show.html.erb +17 -0
  102. data/app/views/alembic/flows/step.html.erb +27 -0
  103. data/app/views/alembic/manage/definitions/edit.html.erb +13 -0
  104. data/app/views/alembic/manage/flows/edit.html.erb +29 -0
  105. data/app/views/alembic/manage/flows/index.html.erb +32 -0
  106. data/app/views/alembic/manage/flows/show.html.erb +26 -0
  107. data/app/views/alembic/manage/pages/_block_content.html.erb +1 -0
  108. data/app/views/alembic/manage/pages/index.html.erb +20 -0
  109. data/app/views/alembic/manage/pages/show.html.erb +11 -0
  110. data/app/views/alembic/manage/versions/index.html.erb +41 -0
  111. data/app/views/layouts/alembic/application.html.erb +17 -0
  112. data/config/routes.rb +42 -0
  113. data/db/migrate/20260604215031_create_alembic_diagnostics.rb +14 -0
  114. data/db/migrate/20260605015051_create_alembic_questions.rb +12 -0
  115. data/db/migrate/20260605021526_create_alembic_options.rb +14 -0
  116. data/db/migrate/20260605023308_create_alembic_conditions.rb +11 -0
  117. data/db/migrate/20260605025623_create_alembic_bands.rb +12 -0
  118. data/db/migrate/20260605032859_create_alembic_condition_options.rb +10 -0
  119. data/db/migrate/20260605032900_reshape_conditions_for_placement.rb +11 -0
  120. data/db/migrate/20260605125119_create_alembic_results.rb +14 -0
  121. data/db/migrate/20260605130338_create_alembic_rules.rb +10 -0
  122. data/db/migrate/20260605130339_create_alembic_rule_results.rb +10 -0
  123. data/db/migrate/20260605210714_add_definition_to_alembic_diagnostics.rb +5 -0
  124. data/db/migrate/20260606000855_add_guide_copy_to_alembic_diagnostics.rb +9 -0
  125. data/db/migrate/20260606005430_create_alembic_nodes.rb +22 -0
  126. data/db/migrate/20260606010312_create_alembic_build_steps.rb +12 -0
  127. data/db/migrate/20260606011110_create_alembic_warnings.rb +11 -0
  128. data/db/migrate/20260804180000_create_alembic_domains.rb +14 -0
  129. data/db/migrate/20260804180100_add_domain_to_alembic_questions.rb +5 -0
  130. data/db/migrate/20260822120000_create_alembic_definition_versions.rb +14 -0
  131. data/db/migrate/20260822120100_backfill_alembic_definition_versions.rb +17 -0
  132. data/db/migrate/20260822130000_remove_definition_from_alembic_diagnostics.rb +28 -0
  133. data/db/migrate/20260822140000_create_alembic_responses.rb +14 -0
  134. data/db/migrate/20260824120000_create_alembic_transitions.rb +10 -0
  135. data/db/migrate/20260824130000_drop_alembic_builder_tables.rb +17 -0
  136. data/db/migrate/20260824140000_add_definition_cursor_to_alembic_diagnostics.rb +5 -0
  137. data/db/migrate/20260824160000_add_summary_definition_to_alembic_diagnostics.rb +5 -0
  138. data/db/migrate/20260824170000_drop_alembic_guide_copy_columns.rb +8 -0
  139. data/db/migrate/20260825120000_create_alembic_summary_versions.rb +14 -0
  140. data/db/migrate/20260825130000_add_summary_cursor_to_alembic_diagnostics.rb +5 -0
  141. data/db/migrate/20260825140000_move_summary_definitions_into_versions.rb +26 -0
  142. data/db/migrate/20260825150000_drop_summary_definition_from_alembic_diagnostics.rb +5 -0
  143. data/db/migrate/20260825160000_add_summary_version_to_alembic_responses.rb +6 -0
  144. data/db/migrate/20260825170000_rename_question_settings_in_current_definitions.rb +51 -0
  145. data/db/migrate/20260825180000_add_document_to_alembic_diagnostics.rb +6 -0
  146. data/db/migrate/20260825190000_seed_documents_from_current_definitions.rb +29 -0
  147. data/db/migrate/20260825200000_add_published_version_to_alembic_diagnostics.rb +6 -0
  148. data/db/migrate/20260825210000_publish_current_definitions.rb +29 -0
  149. data/db/migrate/20260825220000_add_undone_changes_to_alembic_diagnostics.rb +5 -0
  150. data/db/migrate/20260825223322_add_status_to_alembic_definition_versions.rb +8 -0
  151. data/db/migrate/20260825224538_remove_published_version_from_alembic_diagnostics.rb +5 -0
  152. data/db/migrate/20260825225015_change_alembic_diagnostic_status_to_availability.rb +16 -0
  153. data/db/migrate/20260825230000_add_undo_history_to_alembic_diagnostics.rb +5 -0
  154. data/db/migrate/20260825230500_add_changes_to_alembic_definition_versions.rb +5 -0
  155. data/db/migrate/20260826230000_rename_alembic_responses_to_flow_runs.rb +5 -0
  156. data/db/migrate/20260826234500_rename_answers_to_recorded_on_flow_runs.rb +5 -0
  157. data/db/migrate/20260826235000_rename_definition_versions_to_flow_versions.rb +5 -0
  158. data/db/migrate/20260826235500_rename_summary_versions_to_flow_summaries.rb +5 -0
  159. data/db/migrate/20260827000000_rename_diagnostics_to_flows.rb +8 -0
  160. data/db/migrate/20260831000000_add_persists_to_alembic_flows.rb +5 -0
  161. data/db/migrate/20260914120000_create_alembic_pages.rb +9 -0
  162. data/db/migrate/20260915120000_add_blocks_to_alembic_pages.rb +5 -0
  163. data/lib/alembic/engine.rb +43 -0
  164. data/lib/alembic/leftover_stylesheet.rb +14 -0
  165. data/lib/alembic/version.rb +3 -0
  166. data/lib/alembic.rb +34 -0
  167. data/lib/tasks/alembic_tasks.rake +4 -0
  168. metadata +252 -0
@@ -0,0 +1,183 @@
1
+ module Alembic
2
+ module Manage
3
+ class CanvasController < BaseController
4
+ include DrawsCanvas
5
+ def show
6
+ render json: canvas_payload(diagnostic)
7
+ end
8
+
9
+ def create
10
+ objections = Flow::Validator.new(document).violations
11
+ return render json: { error: refusal(objections) }, status: :unprocessable_entity if objections.any?
12
+
13
+ stood_at = diagnostic.current_definition_version&.number
14
+ diagnostic.create_version
15
+
16
+ render json: { notice: created(stood_at) }
17
+ end
18
+
19
+ def publish
20
+ objections = Flow::Validator.new(document).violations
21
+ return render json: { error: refusal(objections) }, status: :unprocessable_entity if objections.any?
22
+
23
+ ran = diagnostic.live_version&.number
24
+ diagnostic.publish
25
+
26
+ render json: { notice: published(ran) }
27
+ end
28
+
29
+ def details
30
+ diagnostic.update!(details_params)
31
+ render json: { notice: "Saved the flow's details." }
32
+ end
33
+
34
+ def undo
35
+ diagnostic.edit_history.undo_change
36
+ head :no_content
37
+ end
38
+
39
+ def redo
40
+ diagnostic.edit_history.redo_change
41
+ head :no_content
42
+ end
43
+
44
+ def add_step
45
+ apply(:added, params[:id]) do |flow|
46
+ next flow.insert(new_step, on: edge_endpoints, leaving: first_port) if placed_on_edge?
47
+ next flow.add(new_step).connect(from: params[:from], to: params[:id], on: params[:on].presence) if branched_from?
48
+
49
+ flow.add(new_step)
50
+ end
51
+ end
52
+
53
+ def configure_step
54
+ apply(:updated, params[:step]) { |flow| flow.configure(params[:step], coerced(flow, params[:step])) }
55
+ end
56
+
57
+ def move_step
58
+ apply(:moved, params[:step]) { |flow| flow.move(params[:step], on: edge_endpoints, leaving: port_for(params[:step])) }
59
+ end
60
+
61
+ def remove_step
62
+ return render json: { error: refusal_to_remove }, status: :unprocessable_entity if begins_the_flow?(params[:step])
63
+
64
+ apply(:removed, params[:step]) { |flow| flow.remove(params[:step]) }
65
+ end
66
+
67
+ def connect
68
+ apply(:connected, params[:from], params[:to]) { |flow| flow.connect(from: params[:from], to: params[:to], on: params[:on].presence) }
69
+ end
70
+
71
+ def disconnect
72
+ apply(:disconnected, params[:from], params[:to]) { |flow| flow.disconnect(from: params[:from], to: params[:to]) }
73
+ end
74
+
75
+ private
76
+
77
+ def apply(action = nil, *steps)
78
+ before = document.to_h
79
+ diagnostic.edit_history.record(yield(document), action, steps, before)
80
+ head :no_content
81
+ rescue Flow::InvalidEdit => invalid
82
+ render json: { error: invalid.message }, status: :unprocessable_entity
83
+ end
84
+
85
+ def diagnostic
86
+ @diagnostic ||= Flow::Definition.find(params[:flow_id])
87
+ end
88
+
89
+ def document
90
+ Flow::Document.new(diagnostic.document || diagnostic.definition || {})
91
+ end
92
+
93
+ def details_params
94
+ params.require(:flow).permit(:title, :summary, :start_label, :persists)
95
+ end
96
+
97
+ def new_step
98
+ { "id" => params.require(:id), "type" => params.require(:type) }
99
+ end
100
+
101
+ def first_port
102
+ first_value_of(Flow::Node.new(id: nil, type: params[:type], config: {}))
103
+ end
104
+
105
+ def port_for(id)
106
+ first_value_of(document.node(id))
107
+ end
108
+
109
+ def first_value_of(node)
110
+ return unless node && Flow.registry.registered?(node.type)
111
+
112
+ step_type = Flow.registry.fetch(node.type)
113
+ return unless step_type.routes?
114
+
115
+ step_type.outputs.flat_map { |output| output.values_for(node) }.first&.fetch("value", nil)&.to_s
116
+ end
117
+
118
+ def begins_the_flow?(id)
119
+ node = document.node(id)
120
+
121
+ node.present? && document.begins_here?(node)
122
+ end
123
+
124
+ def placed_on_edge?
125
+ params[:from].present? && params[:to].present?
126
+ end
127
+
128
+ def branched_from?
129
+ params[:from].present?
130
+ end
131
+
132
+ def edge_endpoints
133
+ [ params[:from], params[:to] ]
134
+ end
135
+
136
+ def configuration
137
+ params.fetch(:config, {}).permit!.to_h
138
+ end
139
+
140
+ def created(stood_at)
141
+ now_at = diagnostic.reload.current_definition_version&.number
142
+ return "Nothing has changed since version #{stood_at}." if now_at == stood_at
143
+
144
+ "Created version #{now_at}."
145
+ end
146
+
147
+ def published(ran)
148
+ now_at = diagnostic.reload.live_version&.number
149
+ return "Visitors already run version #{now_at}." if now_at == ran
150
+
151
+ "Published version #{now_at}. Visitors run it now."
152
+ end
153
+
154
+ def refusal_to_remove
155
+ "Cannot remove where a flow begins."
156
+ end
157
+
158
+ def refusal(objections)
159
+ "Cannot publish: #{objections.map { |problem| worded(problem) }.join(', ')}."
160
+ end
161
+
162
+ def worded(problem)
163
+ trouble = problem.problem.to_s.humanize(capitalize: false)
164
+
165
+ problem.node ? "“#{problem.node}” is #{trouble}" : trouble
166
+ end
167
+
168
+ def coerced(flow, id)
169
+ step_type = Flow::Digest.new(flow).step(id)&.type
170
+ return configuration unless step_type && Flow.registry.registered?(step_type)
171
+
172
+ settled(Flow.registry.fetch(step_type))
173
+ end
174
+
175
+ def settled(step_type)
176
+ step_type.settings.coerce(configuration).tap do |values|
177
+ objections = step_type.settings.objections(values)
178
+ raise Flow::InvalidEdit, objections.join(", ") if objections.any?
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,15 @@
1
+ module Alembic
2
+ module Manage
3
+ class DefinitionsController < BaseController
4
+ def edit
5
+ @diagnostic = Flow::Definition.find(params[:flow_id])
6
+ end
7
+
8
+ def update
9
+ @diagnostic = Flow::Definition.find(params[:flow_id])
10
+ @diagnostic.edit_history.edit_document(JSON.parse(params.require(:definition)))
11
+ redirect_to manage_flow_path(@diagnostic), notice: "Definition saved."
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,55 @@
1
+ module Alembic
2
+ module Manage
3
+ class FlowsController < BaseController
4
+ include DrawsCanvas
5
+ def index
6
+ @diagnostics = ordered_diagnostics
7
+ end
8
+
9
+ def create
10
+ @diagnostic = Flow::Definition.new(create_params)
11
+
12
+ if @diagnostic.save
13
+ redirect_to manage_flow_path(@diagnostic), notice: "Flow created."
14
+ else
15
+ @diagnostics = ordered_diagnostics
16
+ render :index, status: :unprocessable_entity
17
+ end
18
+ end
19
+
20
+ def show
21
+ @diagnostic = Flow::Definition.find(params[:id])
22
+ @canvas = canvas_payload(@diagnostic)
23
+ end
24
+
25
+ def edit
26
+ @diagnostic = Flow::Definition.find(params[:id])
27
+ end
28
+
29
+ def update
30
+ @diagnostic = Flow::Definition.find(params[:id])
31
+ @diagnostic.update!(diagnostic_params)
32
+ redirect_to manage_flow_path(@diagnostic), notice: "Saved."
33
+ end
34
+
35
+ def destroy
36
+ Flow::Definition.find(params[:id]).destroy!
37
+ redirect_to manage_flows_path, notice: "Flow removed."
38
+ end
39
+
40
+ private
41
+
42
+ def ordered_diagnostics
43
+ Flow::Definition.order(:slug)
44
+ end
45
+
46
+ def create_params
47
+ params.require(:flow).permit(:slug, :kind)
48
+ end
49
+
50
+ def diagnostic_params
51
+ params.require(:flow).permit(:title, :summary, :start_label, :persists)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,19 @@
1
+ require "ks_blocks/layout_endpoints"
2
+
3
+ module Alembic
4
+ module Manage
5
+ class PageBlocksController < BaseController
6
+ include KsBlocks::LayoutEndpoints
7
+
8
+ private
9
+
10
+ def block_layout_record
11
+ Page.find(params[:page_id])
12
+ end
13
+
14
+ def block_content(block)
15
+ render_to_string(partial: "alembic/manage/pages/block_content", locals: { block: block }, formats: [ :html ])
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,38 @@
1
+ module Alembic
2
+ module Manage
3
+ class PagesController < BaseController
4
+ def index
5
+ @pages = Page.order(:name)
6
+ end
7
+
8
+ def show
9
+ @page = Page.find(params[:id])
10
+ @payload = payload(@page)
11
+
12
+ respond_to do |format|
13
+ format.html
14
+ format.json { render json: @payload }
15
+ end
16
+ end
17
+
18
+ def create
19
+ page = Page.create!(page_params)
20
+ redirect_to manage_page_path(page)
21
+ end
22
+
23
+ private
24
+
25
+ def payload(page)
26
+ {
27
+ base: manage_page_path(page),
28
+ pages: manage_pages_path,
29
+ name: page.name
30
+ }.merge(page.layout_data)
31
+ end
32
+
33
+ def page_params
34
+ params.require(:page).permit(:name)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,38 @@
1
+ module Alembic
2
+ module Manage
3
+ class PreviewsController < ::Alembic::FlowsController
4
+ include AuthenticatesAdmin
5
+
6
+ def show
7
+ @diagnostic = previewed
8
+ render template: "alembic/flows/show"
9
+ end
10
+
11
+ private
12
+
13
+ def flow_start_path(_slug)
14
+ alembic.manage_flow_preview_path(previewed)
15
+ end
16
+
17
+ def flow_step_path(_slug)
18
+ alembic.step_manage_flow_preview_path(previewed)
19
+ end
20
+
21
+ def previewing?
22
+ true
23
+ end
24
+
25
+ def flowing_definition(diagnostic)
26
+ diagnostic.document
27
+ end
28
+
29
+ def previewed
30
+ @previewed ||= Flow::Definition.find(params[:flow_id])
31
+ end
32
+
33
+ def admit(_diagnostic)
34
+ previewed
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,22 @@
1
+ module Alembic
2
+ module Manage
3
+ class VersionsController < BaseController
4
+ def index
5
+ @diagnostic = diagnostic
6
+ @versions = @diagnostic.definition_versions.order(number: :desc)
7
+ end
8
+
9
+ def return
10
+ diagnostic.return_to(diagnostic.definition_versions.find(params[:id]))
11
+
12
+ redirect_to manage_flow_path(diagnostic), notice: "The flow is back to that version."
13
+ end
14
+
15
+ private
16
+
17
+ def diagnostic
18
+ @diagnostic ||= Flow::Definition.find(params[:flow_id])
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,19 @@
1
+ module Alembic
2
+ module Manage
3
+ module AuthenticatesAdmin
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ before_action :authenticate_admin
8
+ end
9
+
10
+ private
11
+
12
+ def authenticate_admin
13
+ return unless Alembic.admin_authentication_method
14
+
15
+ send(Alembic.admin_authentication_method)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,35 @@
1
+ module Alembic
2
+ module Manage
3
+ module DrawsCanvas
4
+ extend ActiveSupport::Concern
5
+
6
+ private
7
+
8
+ def canvas_payload(diagnostic)
9
+ Flow::Canvas.new(canvas_document(diagnostic)).to_h
10
+ .merge("undoable" => diagnostic.edit_history.undoable?, "redoable" => diagnostic.edit_history.redoable?,
11
+ "changes" => listed_changes(diagnostic), "flow" => flow_details(diagnostic))
12
+ end
13
+
14
+ def canvas_document(diagnostic)
15
+ Flow::Document.new(diagnostic.document || diagnostic.definition || {})
16
+ end
17
+
18
+ def listed_changes(diagnostic)
19
+ diagnostic.changes_since_version.to_a.map { |change| Flow::Change.phrase(change) }
20
+ end
21
+
22
+ def flow_details(diagnostic)
23
+ { "title" => diagnostic.title,
24
+ "slug" => diagnostic.slug,
25
+ "summary" => diagnostic.summary,
26
+ "start_label" => diagnostic.start_label,
27
+ "version" => diagnostic.current_definition_version&.number,
28
+ "published" => diagnostic.live_version&.number,
29
+ "definition_url" => edit_manage_flow_definition_path(diagnostic),
30
+ "details_url" => edit_manage_flow_path(diagnostic),
31
+ "history_url" => manage_flow_versions_path(diagnostic) }
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,11 @@
1
+ module Alembic
2
+ module ApplicationHelper
3
+ def alembic_output_lines(value)
4
+ case value
5
+ when Hash then value.map { |name, share| "#{name}: #{share}" }
6
+ when Array then value
7
+ else [ value ]
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,197 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from "react"
2
+ import StepCard from "./StepCard"
3
+ import Inspector from "./Inspector"
4
+ import Placeholder from "./Placeholder"
5
+ import { offeredTo } from "./choices"
6
+ import TypePicker from "./TypePicker"
7
+ import Toolbar from "./Toolbar"
8
+ import Panel from "./Panel"
9
+ import ConnectorLayer from "./ConnectorLayer"
10
+ import useFlow from "./useFlow"
11
+ import useConnectors from "./useConnectors"
12
+ import { CARD, GAP_X, GAP_Y } from "./styles"
13
+ import { nextId } from "./ids"
14
+
15
+ const page = { display: "flex", height: "100%", minHeight: 0, fontSize: 13 }
16
+ const scroll = { flex: 1, overflow: "auto", position: "relative" }
17
+ const waiting = { position: "absolute", top: 56, left: 16, zIndex: 5, width: CARD + 24, padding: 12, borderRadius: 8 }
18
+
19
+ const grid = { display: "grid", rowGap: GAP_Y, columnGap: 0, padding: 40, justifyContent: "center", position: "relative" }
20
+ const notice = { position: "sticky", zIndex: 8, top: 8, margin: "8px auto 0", width: "fit-content", padding: "6px 12px", borderRadius: 6, fontSize: 12 }
21
+
22
+ export const Choosing = ({ port, onCancel }) => (
23
+ <div className="bg-accent-100 text-accent-800 dark:bg-accent-900 dark:text-accent-100" style={notice}>
24
+ Choose the step “{port || "next"}” should lead to — <button onClick={onCancel} className="text-accent-800 underline dark:text-accent-100" style={{ border: "none", background: "none", cursor: "pointer", fontSize: 12, padding: 0 }}>cancel</button>
25
+ </div>
26
+ )
27
+
28
+ const Canvas = ({ base, token, initial }) => {
29
+ const { flow, error, notice, send } = useFlow(base, token, initial)
30
+ const [ selected, setSelected ] = useState(null)
31
+ const [ adding, setAdding ] = useState(null)
32
+ const [ armed, setArmed ] = useState(null)
33
+ const [ dragging, setDragging ] = useState(null)
34
+ const [ showing, setShowing ] = useState(false)
35
+ const cards = useRef({})
36
+ const surface = useRef(null)
37
+
38
+ useEffect(() => {
39
+ const open = () => { setSelected(null); setShowing(true) }
40
+ document.addEventListener("alembic:open-flow", open)
41
+ return () => document.removeEventListener("alembic:open-flow", open)
42
+ }, [])
43
+
44
+ useEffect(() => {
45
+ if (!showing) return
46
+
47
+ const away = (event) => {
48
+ if (!event.target.closest("[data-builder-panel], [data-open-panel]")) setShowing(false)
49
+ }
50
+ document.addEventListener("click", away)
51
+ return () => document.removeEventListener("click", away)
52
+ }, [ showing ])
53
+
54
+ useEffect(() => {
55
+ document.dispatchEvent(new CustomEvent("alembic:flow-named", { detail: flow.flow?.title || flow.flow?.slug }))
56
+ }, [ flow.flow?.title, flow.flow?.slug ])
57
+
58
+ const byId = useMemo(() => Object.fromEntries(flow.nodes.map((node) => [ node.id, node ])), [ flow.nodes ])
59
+ const { links, extent } = useConnectors(flow, surface, cards, [ flow, selected, byId ])
60
+
61
+ const violationsFor = useMemo(() => {
62
+ const grouped = {}
63
+ flow.violations.forEach((violation) => (grouped[violation.node] ||= []).push(violation))
64
+ return grouped
65
+ }, [ flow.violations ])
66
+
67
+ const attached = flow.nodes.filter((node) => !node.loose)
68
+ const loose = flow.nodes.filter((node) => node.loose)
69
+ const rows = Math.max(0, ...attached.map((node) => node.row)) + 1
70
+ const columns = Math.max(0, ...attached.map((node) => node.column)) + 1
71
+
72
+ const connectTo = (target) => {
73
+ if (!armed) return
74
+
75
+ const [ source, port ] = armed
76
+ setArmed(null)
77
+ if (source !== target) send("/edges", "POST", { from: source, to: target, on: port || null })
78
+ }
79
+
80
+ const selectedNode = flow.nodes.find((node) => node.id === selected)
81
+ const entryFor = flow.palette.find((entry) => entry.type === selectedNode?.type)
82
+
83
+ return (
84
+ <div className="text-gray-900 dark:text-gray-100" style={page} onMouseUp={() => setDragging(null)}
85
+ onKeyDown={(event) => { if (event.key === "Escape") { setSelected(null); setArmed(null); setAdding(null) } }}
86
+ tabIndex={-1}>
87
+ <div ref={surface} className="bg-surface-50 dark:bg-surface-950" style={scroll}
88
+ onClick={(event) => { if (event.target === surface.current) { setSelected(null); setArmed(null); setShowing(false) } }}>
89
+ {armed && (
90
+ <Choosing port={armed[1]} onCancel={() => setArmed(null)} />
91
+ )}
92
+ <Toolbar undoable={flow.undoable} redoable={flow.redoable}
93
+ onAdd={() => setAdding({ at: { x: 16, y: 52 } })}
94
+ onUndo={() => { setSelected(null); send("/undo", "POST") }}
95
+ onRedo={() => { setSelected(null); send("/redo", "POST") }} />
96
+
97
+ <ConnectorLayer links={links} extent={extent} dragging={dragging}
98
+ onInsert={(link) => setAdding({ from: link.source, to: link.target, at: { x: link.midX + 30, y: link.midY } })}
99
+ onRemove={(link) => { setSelected(null); send("/edges", "DELETE", { from: link.source, to: link.target }) }}
100
+ onDrop={(link) => { const held = dragging; setDragging(null); send("/steps/" + held + "/move", "PATCH", { from: link.source, to: link.target }) }} />
101
+
102
+ {loose.length > 0 && (
103
+ <div data-loose className="border border-dashed border-gray-300 bg-gray-50 dark:border-zinc-700 dark:bg-zinc-900" style={waiting}>
104
+ <div className="text-gray-500 dark:text-gray-400" style={{ fontSize: 11, marginBottom: 6 }}>Not in the flow yet — drag one onto a connection</div>
105
+ {loose.map((node) => (
106
+ <div key={node.id} style={{ marginBottom: 8 }}>
107
+ <StepCard
108
+ node={{
109
+ ...node,
110
+ ref: (element) => { cards.current[node.id] = element },
111
+ violations: violationsFor[node.id] || [],
112
+ connected: []
113
+ }}
114
+ selected={selected === node.id}
115
+ armed={armed && armed[0] === node.id ? armed[1] : null}
116
+ connecting={Boolean(armed)}
117
+ onSelect={() => (armed ? connectTo(node.id) : setSelected(node.id))}
118
+ onArm={(port, event) => { event.stopPropagation(); setArmed([ node.id, port ]) }}
119
+ onDragStart={() => setDragging(node.id)}
120
+ onDragEnd={() => setDragging(null)} />
121
+ </div>
122
+ ))}
123
+ </div>
124
+ )}
125
+
126
+ <div style={{ ...grid, gridTemplateColumns: `repeat(${columns + 1}, ${(CARD + GAP_X) / 2}px)`, gridTemplateRows: `repeat(${rows}, auto)` }}>
127
+ {attached.map((node) => (
128
+ <div key={node.id} style={{ gridRow: node.row + 1, gridColumn: `${node.column + 1} / span 2`, zIndex: 2 }}>
129
+ {node.placeholder && (
130
+ <Placeholder node={{ ...node, ref: (element) => { cards.current[node.id] = element } }}
131
+ dragging={Boolean(dragging)}
132
+ onFill={() => setAdding({ from: node.from, on: node.on, at: { x: 16, y: 52 } })}
133
+ onDrop={() => {
134
+ const held = dragging
135
+ setDragging(null)
136
+ if (held) send("/edges", "POST", { from: node.from, to: held, on: node.on })
137
+ }} />
138
+ )}
139
+ {!node.placeholder && <StepCard
140
+ node={{
141
+ ...node,
142
+ ref: (element) => { cards.current[node.id] = element },
143
+ violations: violationsFor[node.id] || [],
144
+ connected: flow.edges.filter((edge) => edge.source === node.id).map((edge) => edge.label || null)
145
+ }}
146
+ selected={node.id === selected}
147
+ armed={armed && armed[0] === node.id ? armed[1] : null}
148
+ connecting={Boolean(armed) && armed[0] !== node.id}
149
+ onSelect={() => (armed ? connectTo(node.id) : setSelected(node.id))}
150
+ onArm={(port, event) => {
151
+ const frame = surface.current.getBoundingClientRect()
152
+ setAdding({
153
+ from: node.id, on: port,
154
+ at: { x: event.clientX - frame.left + surface.current.scrollLeft + 8,
155
+ y: event.clientY - frame.top + surface.current.scrollTop + 8 }
156
+ })
157
+ }}
158
+ onDragEnd={() => setDragging(null)}
159
+ onDragStart={() => setDragging(node.id)}
160
+ />}
161
+ </div>
162
+ ))}
163
+ </div>
164
+
165
+ {adding && (
166
+ <TypePicker entries={flow.palette} at={adding.at} onDismiss={() => setAdding(null)}
167
+ onConnect={adding.on !== undefined ? () => { setArmed([ adding.from, adding.on ]); setAdding(null) } : null}
168
+ onPick={(entry) => {
169
+ const where = adding
170
+ setAdding(null)
171
+ send("/steps", "POST", { id: nextId(entry.type, flow.nodes.map((node) => node.id)), type: entry.type, from: where.from, to: where.to, on: where.on })
172
+ }} />
173
+ )}
174
+ </div>
175
+
176
+ {showing && !selectedNode && (
177
+ <Panel flow={flow.flow || {}} changes={flow.changes || []} problems={flow.violations} refusal={error} notice={notice}
178
+ onClose={() => setShowing(false)}
179
+ onCreate={() => send("/versions", "POST")}
180
+ onPublish={() => send("/publish", "POST")}
181
+ onSaveDetails={(details) => send("/details", "PATCH", { flow: details })} />
182
+ )}
183
+
184
+ {selectedNode && (
185
+ <Inspector node={selectedNode}
186
+ fields={entryFor?.fields || {}} holds={entryFor?.records || {}}
187
+ labels={entryFor?.labels || {}} recordLabels={entryFor?.record_labels || {}}
188
+ choices={offeredTo(selectedNode, entryFor)}
189
+ onClose={() => setSelected(null)}
190
+ onSave={(config) => send(`/steps/${selectedNode.id}`, "PATCH", { config })}
191
+ onDelete={() => { setSelected(null); send(`/steps/${selectedNode.id}`, "DELETE") }} />
192
+ )}
193
+ </div>
194
+ )
195
+ }
196
+
197
+ export default Canvas
@@ -0,0 +1,30 @@
1
+ import React, { useState } from "react"
2
+ import Button from "keystone_ui-react/src/Button.jsx"
3
+ const round = { width: 24, height: 24, padding: 0, fontSize: 15, lineHeight: "15px" }
4
+
5
+ const Connector = ({ link, onInsert, onRemove, onDrop, dragging }) => {
6
+ const [ over, setOver ] = useState(false)
7
+ const showing = over || dragging
8
+
9
+ return (
10
+ <div data-connector={`${link.source}-${link.target}`}
11
+ style={{ position: "absolute", left: link.midX - 34, top: link.midY - 20, width: 68, height: 40, zIndex: 4 }}
12
+ onMouseEnter={() => setOver(true)} onMouseLeave={() => setOver(false)}
13
+ onDragEnter={() => setOver(true)} onDragLeave={() => setOver(false)}
14
+ onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = "move" }}
15
+ onDrop={(event) => { event.preventDefault(); setOver(false); onDrop() }}>
16
+ <div style={{ display: "flex", gap: 4, justifyContent: "center", alignItems: "center", height: "100%",
17
+ opacity: showing ? 1 : 0, transition: "opacity .12s" }}>
18
+ <Button variant="secondary" title={dragging ? "Move the step here" : "Insert a step here"} onClick={onInsert}
19
+ className="rounded-full data-[over=true]:ring-2 data-[over=true]:ring-accent-600"
20
+ data-over={Boolean(dragging && over)} style={round}>+</Button>
21
+ {!dragging && (
22
+ <Button variant="danger" title="Remove this connection" onClick={onRemove}
23
+ className="rounded-full" style={round}>×</Button>
24
+ )}
25
+ </div>
26
+ </div>
27
+ )
28
+ }
29
+
30
+ export default Connector