alembic 0.2.0 → 0.4.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/alembic/one_page.js +39 -0
  3. data/app/controllers/alembic/application_controller.rb +2 -0
  4. data/app/controllers/alembic/flows_controller.rb +31 -0
  5. data/app/controllers/alembic/manage/base_controller.rb +9 -0
  6. data/app/controllers/alembic/manage/flows_controller.rb +37 -3
  7. data/app/controllers/alembic/manage/page_blocks_controller.rb +1 -1
  8. data/app/controllers/alembic/manage/pages_controller.rb +7 -0
  9. data/app/controllers/concerns/alembic/summarizes.rb +35 -2
  10. data/app/helpers/alembic/application_helper.rb +17 -2
  11. data/app/helpers/alembic/content_blocks_helper.rb +31 -0
  12. data/app/helpers/alembic/pages_helper.rb +7 -6
  13. data/app/helpers/alembic/result_blocks_helper.rb +57 -0
  14. data/app/models/alembic/flow/definition_summary.rb +2 -0
  15. data/app/models/alembic/flow/output_form.rb +74 -0
  16. data/app/models/alembic/flow/preview_values.rb +47 -0
  17. data/app/models/alembic/flow/summaries.rb +39 -0
  18. data/app/models/alembic/outputs/band.rb +1 -1
  19. data/app/models/alembic/outputs/lowest.rb +5 -3
  20. data/app/models/alembic/outputs/outcome.rb +33 -0
  21. data/app/models/alembic/page/content_blocks.rb +20 -0
  22. data/app/models/alembic/page/options.rb +14 -7
  23. data/app/models/alembic/page/preview.rb +15 -0
  24. data/app/models/alembic/result_blocks.rb +33 -0
  25. data/app/models/alembic/summary/report.rb +1 -1
  26. data/app/models/alembic/summary/result.rb +1 -1
  27. data/app/views/alembic/blocks/_answers.html.erb +11 -0
  28. data/app/views/alembic/blocks/_band.html.erb +6 -0
  29. data/app/views/alembic/blocks/_categories.html.erb +11 -0
  30. data/app/views/alembic/blocks/_code.html.erb +1 -0
  31. data/app/views/alembic/blocks/_facts.html.erb +5 -0
  32. data/app/views/alembic/blocks/_hero.html.erb +9 -0
  33. data/app/views/alembic/blocks/_lead.html.erb +15 -0
  34. data/app/views/alembic/blocks/_outcome_entry.html.erb +44 -0
  35. data/app/views/alembic/blocks/_outcomes.html.erb +10 -0
  36. data/app/views/alembic/blocks/_score.html.erb +7 -0
  37. data/app/views/alembic/blocks/_table.html.erb +20 -0
  38. data/app/views/alembic/blocks/_weakest.html.erb +12 -0
  39. data/app/views/alembic/flows/_question.html.erb +24 -0
  40. data/app/views/alembic/flows/complete.html.erb +9 -3
  41. data/app/views/alembic/flows/one_page.html.erb +35 -0
  42. data/app/views/alembic/flows/show.html.erb +16 -13
  43. data/app/views/alembic/flows/step.html.erb +47 -0
  44. data/app/views/alembic/flows/summary_page.html.erb +5 -0
  45. data/app/views/alembic/manage/flows/_outputs.html.erb +61 -0
  46. data/app/views/alembic/manage/flows/edit.html.erb +30 -0
  47. data/app/views/alembic/manage/pages/_block_content.html.erb +1 -1
  48. data/app/views/alembic/manage/pages/show.html.erb +10 -1
  49. data/config/routes.rb +1 -0
  50. data/db/migrate/20260930120000_add_summary_page_to_alembic_flow_definition_summaries.rb +6 -0
  51. data/db/migrate/20260930230000_add_shows_answer_values_to_alembic_flow_definition_summaries.rb +5 -0
  52. data/db/migrate/20260930233000_add_asks_on_one_page_to_alembic_flow_definition_summaries.rb +5 -0
  53. data/db/migrate/20261001120000_add_intro_page_to_alembic_flow_definition_summaries.rb +6 -0
  54. data/lib/alembic/engine.rb +10 -0
  55. data/lib/alembic/version.rb +1 -1
  56. data/lib/alembic.rb +5 -1
  57. metadata +33 -9
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0a28b9683aee21510ccc84055b1f946263171af250b924b7fa16b89fe758ae4b
4
- data.tar.gz: f64da9c1a64cc8a20fd48aa05e773973fc1eee8d3a8ee222d3d4de5de9d21154
3
+ metadata.gz: 199da5d98e867968625a2b5c7052a2b9be9be053dae21f2d9335d0985c821934
4
+ data.tar.gz: d267ef04676182a8973f0d69a50ddb774e26ca8ebe2f252325bb04be8dab62a7
5
5
  SHA512:
6
- metadata.gz: fffcf2c82312ff28a404fe1d4569572b9908a1f2b9a30295efaf3c869e246fdcf6258125b5295b2b400898f8b0ffb075ca000b0c33686dadf7d7b0d2d19918fd
7
- data.tar.gz: 6d71eaad3a26a0c2e77a033a16ff6fb791af402a2ce04efaca9c731c84eb53d86253f003d8e4b44b57f3f9de7682e4ad7dadcde8a9278111a653e6137fb55ef0
6
+ metadata.gz: c7e095677fe4cf0074adb09b2dc098da95b580c1ef5effc567425e712b6f7ccc9fd812fb17067c1b5971d136764e7e9de31580f241d921da76af18f9dd5e62c3
7
+ data.tar.gz: b68e766a8b94d801637d278111ceb983e97a7365b4ee8d66219c1cfce334c453f8b531b6db66d499ac76a3540cd181ee27435ac05b85514da5726c78b3295420
@@ -0,0 +1,39 @@
1
+ (() => {
2
+ const start = (form) => {
3
+ if (form.dataset.onePageStarted) return
4
+ form.dataset.onePageStarted = "true"
5
+
6
+ const questions = Array.from(form.querySelectorAll("[data-one-page-question]"))
7
+ const ready = form.querySelector("[data-one-page-ready]")
8
+ const show = (index) => {
9
+ questions.forEach((question, position) => { question.hidden = position !== index })
10
+ ready.hidden = index < questions.length
11
+ }
12
+
13
+ questions.forEach((question, position) => {
14
+ question.querySelectorAll("input[type=radio]").forEach((answer) => {
15
+ answer.addEventListener("change", () => show(position + 1))
16
+ })
17
+ const previous = question.querySelector("[data-one-page-previous]")
18
+ if (previous) {
19
+ previous.hidden = false
20
+ previous.addEventListener("click", () => show(position - 1))
21
+ }
22
+ })
23
+
24
+ const back = ready.querySelector("[data-one-page-previous]")
25
+ back.hidden = false
26
+ back.addEventListener("click", () => show(questions.length - 1))
27
+
28
+ show(0)
29
+ }
30
+
31
+ const begin = () => document.querySelectorAll("form[data-one-page]").forEach(start)
32
+
33
+ document.addEventListener("turbo:load", begin)
34
+ if (document.readyState === "loading") {
35
+ document.addEventListener("DOMContentLoaded", begin)
36
+ } else {
37
+ begin()
38
+ }
39
+ })()
@@ -2,6 +2,8 @@ module Alembic
2
2
  class ApplicationController < Alembic.base_controller.constantize
3
3
  layout -> { Alembic.layout }
4
4
  helper KeystoneUiHelper
5
+ helper ResultBlocksHelper
6
+ helper ContentBlocksHelper
5
7
 
6
8
  rescue_from NotPublished, NotPermitted, Withdrawn, with: :refuse
7
9
 
@@ -2,9 +2,40 @@ module Alembic
2
2
  class FlowsController < EasyFlow::FlowsController
3
3
  hosted_by FLOW_HOST
4
4
  include Summarizes
5
+ helper_method :step_category, :shows_answer_values?
6
+
7
+ def step
8
+ return super unless asked_on_one_page?
9
+
10
+ @guide = runner_for(running_definition)
11
+ @questions = questions_in_order
12
+ render :one_page
13
+ end
5
14
 
6
15
  private
7
16
 
17
+ def asked_on_one_page?
18
+ run.nil? && params[:answers].blank? && Flow::Summaries.new(flow).asks_on_one_page?
19
+ end
20
+
21
+ def questions_in_order
22
+ state = {}
23
+ questions = []
24
+ while (question = @guide.next_step(state))
25
+ questions << question
26
+ state[question.id.to_s] = "asked"
27
+ end
28
+ questions
29
+ end
30
+
31
+ def shows_answer_values?
32
+ Flow::Summaries.new(run&.flow || flow).shows_answer_values?
33
+ end
34
+
35
+ def step_category(question)
36
+ EasyFlow::Steps::Question.category_of(@guide.step(question.id)&.config)
37
+ end
38
+
8
39
  def run_location(run)
9
40
  alembic.run_path(run)
10
41
  end
@@ -12,6 +12,15 @@ module Alembic
12
12
  helper KeystoneUi::React::MountHelper
13
13
  helper KsBlocks::ContentHelper
14
14
  helper Alembic::PagesHelper
15
+ helper Alembic::ResultBlocksHelper
16
+ helper Alembic::ContentBlocksHelper
17
+ helper_method :preview_values
18
+
19
+ private
20
+
21
+ def preview_values(page)
22
+ @preview_values ||= Page::Preview.values_for(page, session.dig(:alembic_preview, page.id.to_s))
23
+ end
15
24
  end
16
25
  end
17
26
  end
@@ -2,16 +2,50 @@ module Alembic
2
2
  module Manage
3
3
  class FlowsController < EasyFlow::Manage::FlowsController
4
4
  hosted_by FLOW_HOST
5
+ helper Alembic::ApplicationHelper
5
6
  def edit
6
7
  super
7
- @summary = Flow::Summaries.new(@flow).text
8
+ summaries = Flow::Summaries.new(@flow)
9
+ @summary = summaries.text
10
+ @summary_page = summaries.summary_page
11
+ @intro_page = summaries.intro_page
12
+ @outputs = Array(summaries.document.to_h["outputs"])
13
+ @uncategorised = scored_by_category?(@outputs) ? questions_of(@flow).reject { |node| EasyFlow::Steps::Question.category_of(node).present? } : []
14
+ @categories = questions_of(@flow).filter_map { |node| EasyFlow::Steps::Question.category_of(node) }.uniq
15
+ @shows_answer_values = summaries.shows_answer_values?
16
+ @asks_on_one_page = summaries.asks_on_one_page?
17
+ @branches = summaries.branches?
18
+ @published_pages = Page.where(id: Page::Version.live.select(:page_id)).order(:name)
8
19
  end
9
20
 
10
21
  def update
11
- summary = params.require(:flow)[:summary]
12
- Flow::Summaries.new(flow_host.flows.find(params[:id])).describe(summary) unless summary.nil?
22
+ details = params.require(:flow)
23
+ summaries = Flow::Summaries.new(flow_host.flows.find(params[:id]))
24
+ summaries.describe(details[:summary]) unless details[:summary].nil?
25
+ summaries.start_on(Page.find_by(id: details[:intro_page_id])) unless details[:intro_page_id].nil?
26
+ summaries.finish_on(Page.find_by(id: details[:summary_page_id])) unless details[:summary_page_id].nil?
27
+ summaries.show_answer_values(details[:shows_answer_values] == "1") unless details[:shows_answer_values].nil?
28
+ summaries.ask_on_one_page(details[:asks_on_one_page] == "1") unless details[:asks_on_one_page].nil?
29
+ record_outputs(summaries, details[:outputs]) if details[:outputs]
13
30
 
14
31
  super
32
+ rescue Summary::UnknownOutputType => refused
33
+ redirect_to easy_flow.edit_manage_flow_path(params[:id]), alert: refused.message
34
+ end
35
+
36
+ private
37
+
38
+ def scored_by_category?(outputs)
39
+ outputs.any? { |output| %w[grouped lowest].include?(output["type"]) }
40
+ end
41
+
42
+ def questions_of(flow)
43
+ Array(flow.definition.to_h["nodes"]).select { |node| node["type"] == "question" }
44
+ end
45
+
46
+ def record_outputs(summaries, submitted)
47
+ edited = Flow::OutputForm.edited(Array(summaries.document.to_h["outputs"]), submitted.permit!.to_h)
48
+ summaries.record(summaries.document.to_h.merge("outputs" => edited))
15
49
  end
16
50
  end
17
51
  end
@@ -12,7 +12,7 @@ module Alembic
12
12
  end
13
13
 
14
14
  def block_content(block)
15
- render_to_string(partial: "alembic/manage/pages/block_content", locals: { block: block }, formats: [ :html ])
15
+ render_to_string(partial: "alembic/manage/pages/block_content", locals: { block: block, page: block_layout_record }, formats: [ :html ])
16
16
  end
17
17
  end
18
18
  end
@@ -27,6 +27,13 @@ module Alembic
27
27
  redirect_to manage_page_path(page)
28
28
  end
29
29
 
30
+ def preview
31
+ page = Page.find(params[:id])
32
+ session[:alembic_preview] = session.fetch(:alembic_preview, {}).merge(page.id.to_s => params[:choice].presence)
33
+
34
+ redirect_to manage_page_path(page)
35
+ end
36
+
30
37
  private
31
38
 
32
39
  def payload(page)
@@ -5,7 +5,11 @@ module Alembic
5
5
  included do
6
6
  layout -> { Alembic.layout }
7
7
  helper ApplicationHelper
8
- helper_method :flow_summary, :first_step_path
8
+ helper PagesHelper
9
+ helper ResultBlocksHelper
10
+ helper ContentBlocksHelper
11
+ helper KsBlocks::LayoutHelper
12
+ helper_method :flow_summary, :first_step_path, :summary_values, :intro_values
9
13
  end
10
14
 
11
15
  private
@@ -25,7 +29,36 @@ module Alembic
25
29
  def finished(answers, finished_run)
26
30
  @outputs = outputs_of(answers.transform_keys(&:to_s))
27
31
  Flow::Summaries.new(flow).pin(finished_run) if finished_run && run.nil?
28
- render template: "alembic/flows/complete"
32
+ @summary_page = Flow::Summaries.new(run&.flow || flow).summary_page
33
+ render template: @summary_page&.live_version ? "alembic/flows/summary_page" : "alembic/flows/complete"
34
+ end
35
+
36
+ def intro_values
37
+ starts_a_run = @flow.each_step? && !previewing?
38
+ { "title" => @flow.title.presence || @flow.slug, "summary" => flow_summary(@flow),
39
+ "question_count" => question_count, "starts_a_run" => starts_a_run,
40
+ "start_path" => starts_a_run ? alembic.flow_runs_path(@flow.slug) : first_step_path(@flow.slug) }
41
+ end
42
+
43
+ def question_count
44
+ Array(@flow.live_definition.to_h["nodes"]).count { |node| node["type"] == "question" }
45
+ end
46
+
47
+ def summary_values
48
+ @outputs.to_h { |output| [ output.id, output.value ] }
49
+ .merge("slug" => @guide.slug, "note" => result_note, "answers" => answers_given)
50
+ end
51
+
52
+ def answers_given
53
+ @answered.map { |step, value| [ @guide.question_text(step), @guide.choice_label(step, value) ] }
54
+ end
55
+
56
+ def result_note
57
+ finished_flow = run&.flow || flow
58
+ return Alembic.lead_note.call(finished_flow.title, @outputs.to_h { |output| [ output.id, output.value ] }) if Alembic.lead_note
59
+
60
+ [ finished_flow.title, *@outputs.map { |output| "#{output.label}: #{helpers.alembic_output_lines(output.value).join(', ')}" } ]
61
+ .compact_blank.join(". ")
29
62
  end
30
63
 
31
64
  def outputs_of(state)
@@ -1,9 +1,24 @@
1
1
  module Alembic
2
2
  module ApplicationHelper
3
+ def alembic_rules_text(rules)
4
+ Array(rules).map do |rule|
5
+ conditions = Array(rule["when"]).map do |condition|
6
+ condition.key?("is_not") ? "#{condition['step']} is not #{condition['is_not']}" : "#{condition['step']} is #{condition['is']}"
7
+ end
8
+ "#{rule['entry']}: #{conditions.join(', ')}".strip
9
+ end.join("\n")
10
+ end
11
+
12
+ def alembic_entry_texts(entry)
13
+ { "facts_text" => Array(entry["facts"]).map { |pair| pair.join(" | ") }.join("\n"),
14
+ "sections_text" => Array(entry["sections"]).map { |section| section.values_at("heading", "text", "note").compact.join(" | ") }.join("\n"),
15
+ "steps_text" => Array(entry["steps"]).map { |step| "#{step['title']}\n#{step['code']}" }.join("\n---\n") }
16
+ end
17
+
3
18
  def alembic_output_lines(value)
4
19
  case value
5
- when Hash then value.map { |name, share| "#{name}: #{share}" }
6
- when Array then value
20
+ when Hash then value.key?("name") ? [ value["name"] ] : value.map { |name, share| "#{name}: #{share}" }
21
+ when Array then value.map { |item| item.is_a?(Hash) ? item["name"] : item }
7
22
  else [ value ]
8
23
  end
9
24
  end
@@ -0,0 +1,31 @@
1
+ module Alembic
2
+ module ContentBlocksHelper
3
+ def alembic_hero_block(headline: nil, kicker: nil, text: nil)
4
+ render "alembic/blocks/hero", headline: headline, kicker: kicker, text: text
5
+ end
6
+
7
+ def alembic_facts_block(question_count: nil)
8
+ facts = yield.to_s.lines.map(&:strip).compact_blank.map do |line|
9
+ line.gsub("{question_count}", question_count.to_s).split("|", 2).map(&:strip)
10
+ end
11
+ render "alembic/blocks/facts", facts: facts
12
+ end
13
+
14
+ START_LOOK = "inline-flex items-center justify-center font-semibold rounded-lg bg-accent-600 text-white hover:bg-accent-500 px-6 py-3 cursor-pointer".freeze
15
+
16
+ def alembic_start_block(start_path:, label: "Start →", starts_a_run: false)
17
+ return button_to(label, start_path, method: :post, class: START_LOOK) if starts_a_run
18
+
19
+ link_to label, start_path, class: START_LOOK
20
+ end
21
+
22
+ def alembic_code_block
23
+ render "alembic/blocks/code", code: yield.to_s
24
+ end
25
+
26
+ def alembic_table_block
27
+ heading, *rows = yield.to_s.lines.map(&:strip).compact_blank.map { |line| line.split("|").map(&:strip) }
28
+ render "alembic/blocks/table", heading: heading.to_a, rows: rows
29
+ end
30
+ end
31
+ end
@@ -4,28 +4,29 @@ module Alembic
4
4
  module PagesHelper
5
5
  UNDRAWABLE = "This block cannot be drawn until its fields are filled in.".freeze
6
6
 
7
- def drawn_page(page, being_edited: false)
7
+ def drawn_page(page, being_edited: false, values: {})
8
8
  blocks = being_edited ? page.blocks : page.live_version&.blocks.to_a
9
- drawn = blocks.to_h { |block| [ block["id"], drawn_block(block) ] }.compact
9
+ drawn = blocks.to_h { |block| [ block["id"], drawn_block(block, values: values) ] }.compact
10
10
 
11
11
  block_layout(blocks.select { |block| drawn.key?(block["id"]) }, kind: :pages) do |block|
12
12
  drawn.fetch(block["id"])
13
13
  end
14
14
  end
15
15
 
16
- def drawn_block(block)
16
+ def drawn_block(block, values: {})
17
17
  component = Page::Drawing.of(block["type"])
18
18
  return unless component
19
19
 
20
+ options = Page::Options.for(block, values: values)
20
21
  body = Page::Options.body_for(block)
21
- body.nil? ? send(component, **Page::Options.for(block)) : send(component, **Page::Options.for(block)) { body }
22
+ body.nil? ? send(component, **options) : send(component, **options) { body }
22
23
  rescue StandardError => undrawable
23
24
  Rails.logger.error("Alembic could not draw the #{block['type']} block: #{undrawable.message}")
24
25
  nil
25
26
  end
26
27
 
27
- def shown_block(block)
28
- drawn_block(block) || undrawn_block(block)
28
+ def shown_block(block, values: {})
29
+ drawn_block(block, values: values) || undrawn_block(block)
29
30
  end
30
31
 
31
32
  private
@@ -0,0 +1,57 @@
1
+ module Alembic
2
+ module ResultBlocksHelper
3
+ def alembic_score_block(value: nil, caption: nil, kicker: "Your result")
4
+ render "alembic/blocks/score", value: value, caption: caption, kicker: kicker
5
+ end
6
+
7
+ def alembic_band_block(value: nil, score: nil)
8
+ render "alembic/blocks/band", band: value.to_h, tone: alembic_tone(score)
9
+ end
10
+
11
+ def alembic_categories_block(value: nil, heading: "Where you stand, area by area")
12
+ render "alembic/blocks/categories", shares: value.to_h, heading: heading
13
+ end
14
+
15
+ def alembic_weakest_block(value: nil, heading: "Your biggest blind spots", cost_label: "What it likely costs you:")
16
+ render "alembic/blocks/weakest", categories: Array(value), heading: heading, cost_label: cost_label
17
+ end
18
+
19
+ def alembic_answers_block(value: nil, heading: "What you told us")
20
+ render "alembic/blocks/answers", answers: Array(value), heading: heading
21
+ end
22
+
23
+ def alembic_lead_block(slug:, note: nil, heading: "The next step", blurb: nil, button: "Send", placeholder: "you@example.com")
24
+ render "alembic/blocks/lead", address: Alembic.lead_address.call(slug), slug: slug, note: note,
25
+ heading: heading, blurb: blurb, button: button, placeholder: placeholder
26
+ end
27
+
28
+ def alembic_default_result(output, outputs = [])
29
+ case output.type
30
+ when "percentage" then alembic_score_block(value: output.value, caption: output.label)
31
+ when "band" then alembic_band_block(value: output.value, score: outputs.find { |other| other.type == "percentage" }&.value)
32
+ when "grouped" then alembic_categories_block(value: output.value)
33
+ when "lowest" then alembic_weakest_block(value: output.value)
34
+ when "outcome" then alembic_outcome_block(value: output.value)
35
+ else ui_stat_card(label: output.label, value: alembic_output_lines(output.value).join(" · "))
36
+ end
37
+ end
38
+
39
+ def alembic_outcome_block(value: nil)
40
+ chosen = value.to_h["chosen"]
41
+ return "" if chosen.nil?
42
+
43
+ render "alembic/blocks/outcome_entry", entry: chosen, open: true
44
+ end
45
+
46
+ def alembic_outcomes_block(value: nil, heading: nil)
47
+ render "alembic/blocks/outcomes", entries: Array(value.to_h["entries"]), heading: heading
48
+ end
49
+
50
+ def alembic_tone(percentage)
51
+ return "bg-red-500" if percentage.to_i < 40
52
+ return "bg-amber-500" if percentage.to_i < 70
53
+
54
+ "bg-emerald-500"
55
+ end
56
+ end
57
+ end
@@ -4,6 +4,8 @@ module Alembic
4
4
  self.table_name = "alembic_flow_definition_summaries"
5
5
 
6
6
  belongs_to :flow, class_name: "EasyFlow::Definition"
7
+ belongs_to :summary_page, class_name: "Alembic::Page", optional: true
8
+ belongs_to :intro_page, class_name: "Alembic::Page", optional: true
7
9
  end
8
10
  end
9
11
  end
@@ -0,0 +1,74 @@
1
+ module Alembic
2
+ module Flow
3
+ module OutputForm
4
+ def self.edited(current, submitted)
5
+ edited = submitted.sort_by { |index, _| index.to_i }.map do |index, output|
6
+ current.fetch(index.to_i, {}).merge(output.except("rules_text")).merge(listed_bands(output)).merge(counted(output))
7
+ .merge(ruled(output)).merge(listed_entries(output))
8
+ end
9
+ edited.select { |output| output["id"].present? }
10
+ end
11
+
12
+ def self.listed_entries(output)
13
+ return {} unless output["entries"].is_a?(Hash)
14
+
15
+ entries = output["entries"].sort_by { |position, _| position.to_i }.map(&:last).select { |entry| entry["key"].present? }
16
+ { "entries" => entries.map { |entry| entry_from(entry) } }
17
+ end
18
+
19
+ def self.entry_from(entry)
20
+ entry.except("facts_text", "sections_text", "steps_text")
21
+ .merge("facts" => pairs(entry["facts_text"]), "sections" => sections(entry["sections_text"]), "steps" => steps(entry["steps_text"]))
22
+ end
23
+
24
+ def self.steps(text)
25
+ text.to_s.split(/^---\s*$/).map(&:strip).compact_blank.map do |block|
26
+ title, code = block.split("\n", 2)
27
+ { "title" => title.strip, "code" => code.to_s.strip }
28
+ end
29
+ end
30
+
31
+ def self.sections(text)
32
+ text.to_s.lines.map(&:strip).compact_blank.map do |line|
33
+ heading, body, note = line.split("|", 3).map(&:strip)
34
+ { "heading" => heading, "text" => body, "note" => note }
35
+ end
36
+ end
37
+
38
+ def self.pairs(text)
39
+ text.to_s.lines.map(&:strip).compact_blank.map { |line| line.split("|", 2).map(&:strip) }
40
+ end
41
+
42
+ def self.ruled(output)
43
+ return {} unless output.key?("rules_text")
44
+
45
+ rules = output["rules_text"].to_s.lines.map(&:strip).compact_blank.map do |line|
46
+ entry, conditions = line.split(":", 2)
47
+ { "entry" => entry.strip, "when" => conditions.to_s.split(",").map(&:strip).compact_blank.map { |condition| condition_from(condition) } }
48
+ end
49
+ { "rules" => rules }
50
+ end
51
+
52
+ def self.condition_from(text)
53
+ step, answer = text.split(/\s+is not\s+/, 2)
54
+ return { "step" => step, "is_not" => answer } if answer
55
+
56
+ step, answer = text.split(/\s+is\s+/, 2)
57
+ { "step" => step, "is" => answer }
58
+ end
59
+
60
+ def self.counted(output)
61
+ output.key?("count") ? { "count" => output["count"].presence&.to_i } : {}
62
+ end
63
+
64
+ def self.listed_bands(output)
65
+ return {} unless output["bands"].is_a?(Hash)
66
+
67
+ named = output["bands"].sort_by { |position, _| position.to_i }.map(&:last).select { |band| band["name"].present? }
68
+ { "bands" => named.map { |band| band.merge("ceiling" => band["ceiling"].presence&.to_i) } }
69
+ end
70
+
71
+ private_class_method :counted, :listed_bands, :ruled, :condition_from, :listed_entries, :pairs, :entry_from, :sections, :steps
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,47 @@
1
+ module Alembic
2
+ module Flow
3
+ module PreviewValues
4
+ def self.values(page, choice)
5
+ flow = flow_for(page)
6
+ return {} if flow.nil?
7
+
8
+ summaries = Summaries.new(flow)
9
+ state = finished_runs(flow).find { |run| run.id.to_s == choice.to_s }&.recorded || sample_state(flow)
10
+ introduced(flow, summaries).merge(summaries.of(state).to_h { |result| [ result.id, result.value ] })
11
+ end
12
+
13
+ def self.finished_runs(flow)
14
+ EasyFlow::Run.where(flow: flow).order(created_at: :desc).limit(20).select { |run| run.next_step(run.recorded).nil? }
15
+ end
16
+
17
+ def self.introduced(flow, summaries)
18
+ questions = Array(flow.live_definition.to_h["nodes"]).count { |node| node["type"] == "question" }
19
+ { "title" => flow.title.presence || flow.slug, "summary" => summaries.text, "question_count" => questions,
20
+ "slug" => flow.slug, "start_path" => "#", "starts_a_run" => false }
21
+ end
22
+
23
+ def self.choices(page)
24
+ flow = flow_for(page)
25
+ return [] if flow.nil?
26
+
27
+ finished_runs(flow).map { |run| [ "Run of #{I18n.l(run.created_at, format: :short)}", run.id.to_s ] }
28
+ end
29
+
30
+ def self.flow_for(page)
31
+ DefinitionSummary.where(summary_page: page).or(DefinitionSummary.where(intro_page: page)).first&.flow
32
+ end
33
+
34
+ def self.sample_state(flow)
35
+ questions = Array(flow.live_definition.to_h["nodes"]).select { |node| node["type"] == "question" }
36
+ questions.each_with_index.filter_map do |question, index|
37
+ answers = EasyFlow::Steps::Question.answers_of(question)
38
+ next if answers.empty?
39
+
40
+ [ question["id"], answers[index % answers.size].then { |answer| answer.is_a?(Hash) ? answer["value"] : answer } ]
41
+ end.to_h
42
+ end
43
+
44
+ private_class_method :flow_for, :introduced, :finished_runs
45
+ end
46
+ end
47
+ end
@@ -14,6 +14,7 @@ module Alembic
14
14
  end
15
15
 
16
16
  def record(payload)
17
+ Array(payload.to_h["outputs"]).each { |output| Summary.registry.fetch(output["type"]) if output["type"].present? }
17
18
  versions.create!(number: next_number, summary: payload)
18
19
  .tap { |version| details.update!(summary_cursor: version.number) }
19
20
  end
@@ -34,6 +35,44 @@ module Alembic
34
35
  details.update!(summary: text)
35
36
  end
36
37
 
38
+ def summary_page
39
+ details.summary_page
40
+ end
41
+
42
+ def shows_answer_values?
43
+ details.shows_answer_values
44
+ end
45
+
46
+ def show_answer_values(shown)
47
+ details.update!(shows_answer_values: shown)
48
+ end
49
+
50
+ def asks_on_one_page?
51
+ details.asks_on_one_page
52
+ end
53
+
54
+ def ask_on_one_page(asked)
55
+ details.update!(asks_on_one_page: asked && !branches?)
56
+ end
57
+
58
+ def branches?
59
+ Array(@flow.definition.to_h["nodes"]).any? do |node|
60
+ EasyFlow.registry.registered?(node["type"]) && EasyFlow.registry.fetch(node["type"]).routes?
61
+ end
62
+ end
63
+
64
+ def intro_page
65
+ details.intro_page
66
+ end
67
+
68
+ def start_on(page)
69
+ details.update!(intro_page: page)
70
+ end
71
+
72
+ def finish_on(page)
73
+ details.update!(summary_page: page)
74
+ end
75
+
37
76
  def pin(run)
38
77
  RunSummary.create!(run: run, summary_version: current_version) if current_version
39
78
  end
@@ -15,7 +15,7 @@ module Alembic
15
15
  def self.covering(config, number)
16
16
  ordered = Array(config["bands"]).sort_by { |band| band["ceiling"] || Float::INFINITY }
17
17
 
18
- ordered.find { |band| band["ceiling"].nil? || number < band["ceiling"] }&.fetch("name", nil)
18
+ ordered.find { |band| band["ceiling"].nil? || number < band["ceiling"] }&.slice("name", "description")
19
19
  end
20
20
  end
21
21
  end
@@ -4,7 +4,7 @@ module Alembic
4
4
  def self.output_type
5
5
  Summary::OutputType.define(:lowest) do
6
6
  label "Weakest"
7
- compute { |config, _run, so_far| Lowest.of(so_far[config["of"]], config["count"]) }
7
+ compute { |config, _run, so_far| Lowest.of(so_far[config["of"]], config["count"], config["copy"]) }
8
8
  end
9
9
  end
10
10
 
@@ -12,8 +12,10 @@ module Alembic
12
12
  registry.register(output_type)
13
13
  end
14
14
 
15
- def self.of(shares, count)
16
- shares.to_h.sort_by { |_name, share| share }.first(count.presence&.to_i || 1).map(&:first)
15
+ def self.of(shares, count, copy = nil)
16
+ weakest = shares.to_h.sort_by { |_name, share| share }.first(count.presence&.to_i || 1).map(&:first)
17
+
18
+ weakest.map { |name| { "name" => name, **copy.to_h.fetch(name, {}).slice("miss", "cost") } }
17
19
  end
18
20
  end
19
21
  end
@@ -0,0 +1,33 @@
1
+ module Alembic
2
+ module Outputs
3
+ module Outcome
4
+ def self.output_type
5
+ Summary::OutputType.define(:outcome) do
6
+ label "Outcome"
7
+ compute { |config, run, _so_far| Outcome.placed(config, run.state) }
8
+ end
9
+ end
10
+
11
+ def self.register(registry = Summary.registry)
12
+ registry.register(output_type)
13
+ end
14
+
15
+ def self.placed(config, state)
16
+ entries = Array(config["entries"])
17
+ rule = Array(config["rules"]).find { |candidate| holds?(candidate, state) }
18
+
19
+ { "chosen" => entries.find { |entry| entry["key"] == rule&.dig("entry") }, "entries" => entries }
20
+ end
21
+
22
+ def self.holds?(rule, state)
23
+ Array(rule["when"]).all? { |condition| met?(condition, state[condition["step"]].to_s) }
24
+ end
25
+
26
+ def self.met?(condition, answer)
27
+ return answer != condition["is_not"].to_s if condition.key?("is_not")
28
+
29
+ answer == condition["is"].to_s
30
+ end
31
+ end
32
+ end
33
+ end