acts_as_calculator_editor 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 (69) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +107 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/javascripts/acts_as_calculator_editor/application.js +26 -0
  6. data/app/assets/javascripts/acts_as_calculator_editor/dentaku_tokenizer.js +139 -0
  7. data/app/assets/javascripts/acts_as_calculator_editor/expression_editor_controller.js +380 -0
  8. data/app/assets/javascripts/acts_as_calculator_editor/list_filter_controller.js +39 -0
  9. data/app/assets/javascripts/acts_as_calculator_editor/template_editor_controller.js +86 -0
  10. data/app/assets/stylesheets/acts_as_calculator_editor/application.css +2 -0
  11. data/app/controllers/acts_as_calculator_editor/application_controller.rb +115 -0
  12. data/app/controllers/acts_as_calculator_editor/exports_controller.rb +41 -0
  13. data/app/controllers/acts_as_calculator_editor/formula_versions_controller.rb +128 -0
  14. data/app/controllers/acts_as_calculator_editor/formulas_controller.rb +94 -0
  15. data/app/controllers/acts_as_calculator_editor/lookup_tables_controller.rb +112 -0
  16. data/app/controllers/acts_as_calculator_editor/templates_controller.rb +130 -0
  17. data/app/controllers/acts_as_calculator_editor/validations_controller.rb +23 -0
  18. data/app/helpers/acts_as_calculator_editor/application_helper.rb +85 -0
  19. data/app/jobs/acts_as_calculator_editor/application_job.rb +6 -0
  20. data/app/mailers/acts_as_calculator_editor/application_mailer.rb +8 -0
  21. data/app/models/acts_as_calculator_editor/application_record.rb +7 -0
  22. data/app/views/acts_as_calculator_editor/formula_versions/_expression_field.html.erb +64 -0
  23. data/app/views/acts_as_calculator_editor/formula_versions/_variable_fields.html.erb +51 -0
  24. data/app/views/acts_as_calculator_editor/formula_versions/index.html.erb +45 -0
  25. data/app/views/acts_as_calculator_editor/formula_versions/new.html.erb +80 -0
  26. data/app/views/acts_as_calculator_editor/formula_versions/show.html.erb +75 -0
  27. data/app/views/acts_as_calculator_editor/formulas/_form.html.erb +23 -0
  28. data/app/views/acts_as_calculator_editor/formulas/edit.html.erb +11 -0
  29. data/app/views/acts_as_calculator_editor/formulas/index.html.erb +71 -0
  30. data/app/views/acts_as_calculator_editor/formulas/new.html.erb +10 -0
  31. data/app/views/acts_as_calculator_editor/formulas/show.html.erb +72 -0
  32. data/app/views/acts_as_calculator_editor/lookup_tables/_entry_fields.html.erb +24 -0
  33. data/app/views/acts_as_calculator_editor/lookup_tables/_form.html.erb +47 -0
  34. data/app/views/acts_as_calculator_editor/lookup_tables/edit.html.erb +12 -0
  35. data/app/views/acts_as_calculator_editor/lookup_tables/index.html.erb +67 -0
  36. data/app/views/acts_as_calculator_editor/lookup_tables/new.html.erb +11 -0
  37. data/app/views/acts_as_calculator_editor/lookup_tables/show.html.erb +71 -0
  38. data/app/views/acts_as_calculator_editor/shared/_error_message.html.erb +12 -0
  39. data/app/views/acts_as_calculator_editor/shared/_export_panel.html.erb +52 -0
  40. data/app/views/acts_as_calculator_editor/shared/_flash.html.erb +8 -0
  41. data/app/views/acts_as_calculator_editor/shared/_page_header.html.erb +16 -0
  42. data/app/views/acts_as_calculator_editor/shared/error.html.erb +11 -0
  43. data/app/views/acts_as_calculator_editor/templates/_form.html.erb +124 -0
  44. data/app/views/acts_as_calculator_editor/templates/_lexxy_toolbar.html.erb +101 -0
  45. data/app/views/acts_as_calculator_editor/templates/index.html.erb +83 -0
  46. data/app/views/acts_as_calculator_editor/templates/new.html.erb +13 -0
  47. data/app/views/acts_as_calculator_editor/templates/preview.html.erb +29 -0
  48. data/app/views/acts_as_calculator_editor/templates/show.html.erb +111 -0
  49. data/app/views/layouts/acts_as_calculator_editor/application.html.erb +52 -0
  50. data/config/importmap.rb +13 -0
  51. data/config/routes.rb +46 -0
  52. data/lib/acts_as_calculator_editor/build_export_document.rb +37 -0
  53. data/lib/acts_as_calculator_editor/build_sample_result.rb +26 -0
  54. data/lib/acts_as_calculator_editor/cast_entry_rows.rb +52 -0
  55. data/lib/acts_as_calculator_editor/cast_export_decimal.rb +19 -0
  56. data/lib/acts_as_calculator_editor/cast_variable_rows.rb +90 -0
  57. data/lib/acts_as_calculator_editor/delete_lookup_table.rb +54 -0
  58. data/lib/acts_as_calculator_editor/engine.rb +32 -0
  59. data/lib/acts_as_calculator_editor/export_formulas.rb +89 -0
  60. data/lib/acts_as_calculator_editor/export_lookup_tables.rb +49 -0
  61. data/lib/acts_as_calculator_editor/export_owner_reference.rb +18 -0
  62. data/lib/acts_as_calculator_editor/export_templates.rb +51 -0
  63. data/lib/acts_as_calculator_editor/publish_lookup_table.rb +53 -0
  64. data/lib/acts_as_calculator_editor/render_template_preview.rb +60 -0
  65. data/lib/acts_as_calculator_editor/validate_expression.rb +127 -0
  66. data/lib/acts_as_calculator_editor/version.rb +5 -0
  67. data/lib/acts_as_calculator_editor.rb +76 -0
  68. data/lib/tasks/acts_as_calculator_editor_tasks.rake +6 -0
  69. metadata +248 -0
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculatorEditor
4
+ # Templates get version history, not effective-dating — there are no `effective_from`
5
+ # /`effective_to` columns and no UI here should imply otherwise
6
+ # (docs/core-gem-contract.md §2.5). Changing a body publishes a new row
7
+ # (`PublishTemplate`); undoing one points `current` back at an older row
8
+ # (`PromoteTemplate`). Neither is an update, which is why there is no `update` action.
9
+ #
10
+ # `new?from_id=` prefills from an existing template, which is the "edit this template"
11
+ # affordance: it lands as a new version rather than a rewrite of the old one.
12
+ class TemplatesController < ApplicationController
13
+ def index
14
+ @templates = filter_by_key_and_scope(scoped_templates)
15
+ @history = history?
16
+ end
17
+
18
+ def show
19
+ @template = template
20
+ @siblings = ActsAsCalculator::Template.version_siblings_of(template).latest_first
21
+ end
22
+
23
+ def new
24
+ @form = prefilled_form
25
+ end
26
+
27
+ def create
28
+ @form = submitted_form
29
+
30
+ rescuing_core_errors(:new) do
31
+ published = ActsAsCalculator::PublishTemplate.(**template_attributes)
32
+ redirect_to template_path(published),
33
+ notice: "Published version #{published.version_number} of #{published.key}."
34
+ end
35
+ end
36
+
37
+ def destroy
38
+ template.destroy!
39
+ redirect_to templates_path, notice: "Deleted #{template.key} version #{template.version_number}."
40
+ end
41
+
42
+ # Rollback. The row that went wrong stays in the history; `current` just stops pointing
43
+ # at it.
44
+ def promote
45
+ promoted = ActsAsCalculator::PromoteTemplate.(template:)
46
+ redirect_to template_path(promoted),
47
+ notice: "Version #{promoted.version_number} of #{promoted.key} is now current."
48
+ end
49
+
50
+ # Renders whatever body was posted, through the real Liquid sandbox, against a synthetic
51
+ # `Result` — no persisted template, no throwaway version row (contract §4.3). The member
52
+ # route falls back to the stored body so a saved template can be previewed from its own
53
+ # page; the collection route is what the new-template form posts to while typing.
54
+ #
55
+ # `ActsAsCalculator::TemplateRenderError` carries Liquid's own line number, so a failure
56
+ # is shown in the preview pane rather than swallowed.
57
+ def preview
58
+ @template = params[:id].present? ? template : nil
59
+ @preview_format = preview_format
60
+ @preview_body = RenderTemplatePreview.(body: preview_source, format: @preview_format,
61
+ context: params[:context])
62
+ render :preview
63
+ rescue ActsAsCalculator::Error => e
64
+ @preview_error = e.message
65
+ render :preview, status: :unprocessable_entity
66
+ end
67
+
68
+ private
69
+
70
+ def template
71
+ @template ||= ActsAsCalculator::Template.find(params[:id])
72
+ end
73
+
74
+ # Current versions only unless asked otherwise: a list of every version of every
75
+ # template is history, not the thing an operator came to look at.
76
+ def scoped_templates
77
+ relation = ActsAsCalculator::Template.order(:scope, :key, version_number: :desc)
78
+ history? ? relation : relation.current
79
+ end
80
+
81
+ def history?
82
+ %w[true 1].include?(params[:history].to_s)
83
+ end
84
+
85
+ def prefilled_form
86
+ source = params[:from_id].presence && ActsAsCalculator::Template.find(params[:from_id])
87
+ return blank_form if source.nil?
88
+
89
+ { "key" => source.key, "scope" => source.scope, "format" => source.format, "body" => source.body }
90
+ end
91
+
92
+ def blank_form
93
+ { "key" => "", "scope" => ActsAsCalculator::DEFAULT_SCOPE,
94
+ "format" => ActsAsCalculator::Template::HTML, "body" => "" }
95
+ end
96
+
97
+ def submitted_form
98
+ permitted = template_params
99
+
100
+ { "key" => permitted[:key].to_s, "scope" => permitted[:scope].to_s,
101
+ "format" => permitted[:format].to_s, "body" => permitted[:body].to_s }
102
+ end
103
+
104
+ def template_params
105
+ params.require(:template).permit(:key, :scope, :format, :body)
106
+ end
107
+
108
+ def template_attributes
109
+ { key: @form["key"],
110
+ body: @form["body"],
111
+ scope: @form["scope"].presence,
112
+ format: @form["format"].presence || ActsAsCalculator::Template::HTML,
113
+ owner: current_owner }
114
+ end
115
+
116
+ def preview_source
117
+ posted = params.dig(:template, :body)
118
+ return posted.to_s if posted.present?
119
+
120
+ params[:id].present? ? template.body : ""
121
+ end
122
+
123
+ def preview_format
124
+ posted = params.dig(:template, :format)
125
+ return posted.to_s if ActsAsCalculator::Template::FORMATS.include?(posted.to_s)
126
+
127
+ params[:id].present? ? template.format : ActsAsCalculator::Template::HTML
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculatorEditor
4
+ # The engine's only JSON endpoint, and the exception that proves the rule in
5
+ # docs/core-gem-contract.md §1: this is a live-typing helper for the expression field, not
6
+ # a CRUD surface. It reads nothing but lookup-table existence and writes nothing at all.
7
+ #
8
+ # Debounce it from the client. It parses an expression and runs at most one indexed lookup
9
+ # per declared `lookup` variable, so it is cheap — but it is still a request per keystroke
10
+ # if nobody says otherwise.
11
+ class ValidationsController < ApplicationController
12
+ # Forced rather than negotiated so a `fetch` that forgets its Accept header still gets
13
+ # JSON back, including for the error paths in `ApplicationController`.
14
+ before_action { request.format = :json }
15
+
16
+ def create
17
+ render json: ValidateExpression.(expression: params[:expression],
18
+ variables: CastVariableRows.(params[:variables]),
19
+ scope: params[:scope],
20
+ owner: current_owner)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculatorEditor
4
+ module ApplicationHelper
5
+ # Derived at runtime, never hardcoded. docs/core-gem-contract.md §5 is explicit about
6
+ # why: a host app may register its own functions into the same registry, so a list
7
+ # copied into JavaScript goes stale the moment somebody does. The autocomplete and the
8
+ # "unknown function" colouring both read this.
9
+ #
10
+ # `Dentaku::AST::Function.registry` holds the builtins (57 of them); the gem's own
11
+ # `FunctionRegistry.default` holds `bracket`, `progressive_bracket`, `round_currency` and
12
+ # `prorate` plus anything the host added. Names come back in mixed shapes across the two,
13
+ # so they are folded to strings and uniqued case-insensitively — matching Dentaku's own
14
+ # case-insensitive resolution.
15
+ def calculator_function_names
16
+ @calculator_function_names ||= begin
17
+ gem_functions = ActsAsCalculator::FunctionRegistry.default.to_a.map { |fn| fn.name.to_s }
18
+ builtins = Dentaku::AST::Function.registry.keys.map(&:to_s)
19
+
20
+ (gem_functions + builtins).uniq(&:downcase).sort
21
+ rescue StandardError
22
+ # A registry shape change upstream must not take the whole form down with it — the
23
+ # editor degrades to "no autocomplete", and the server-side check still catches an
24
+ # undefined function name.
25
+ []
26
+ end
27
+ end
28
+
29
+ # Turbo must not touch a file response: it has no way to render one, and swallowing the
30
+ # navigation leaves the operator on the page with nothing downloaded.
31
+ def export_link_options(extra = {})
32
+ { data: { turbo: false } }.deep_merge(extra)
33
+ end
34
+
35
+ NAV_BASE = "rounded-md px-3 py-1.5 font-medium transition-colors"
36
+
37
+ def nav_class(current)
38
+ if current
39
+ "#{NAV_BASE} bg-slate-900 text-white"
40
+ else
41
+ "#{NAV_BASE} text-slate-600 hover:bg-slate-100 hover:text-slate-900"
42
+ end
43
+ end
44
+
45
+ # Shared input chrome, so a field looks the same on every form without each view
46
+ # restating a dozen utility classes.
47
+ def field_class(extra = nil)
48
+ base = "block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm " \
49
+ "shadow-sm placeholder:text-slate-400 focus:border-slate-500 focus:outline-none " \
50
+ "focus:ring-2 focus:ring-slate-200 disabled:bg-slate-100 read-only:bg-slate-100"
51
+ [base, extra].compact.join(" ")
52
+ end
53
+
54
+ def label_class
55
+ "mb-1 block text-sm font-medium text-slate-700"
56
+ end
57
+
58
+ def hint_class
59
+ "mt-1 block text-xs text-slate-500"
60
+ end
61
+
62
+ def button_class(variant = :primary)
63
+ base = "inline-flex items-center justify-center rounded-md px-3 py-2 text-sm font-medium " \
64
+ "transition-colors focus:outline-none focus:ring-2 focus:ring-offset-1"
65
+ variants = {
66
+ primary: "bg-slate-900 text-white hover:bg-slate-700 focus:ring-slate-400",
67
+ danger: "bg-white text-red-700 border border-red-300 hover:bg-red-50 focus:ring-red-300",
68
+ secondary: "bg-white text-slate-700 border border-slate-300 hover:bg-slate-50 focus:ring-slate-300"
69
+ }
70
+ "#{base} #{variants.fetch(variant, variants[:secondary])}"
71
+ end
72
+
73
+ # A status pill for a formula version or template row.
74
+ def status_badge(status)
75
+ palette = {
76
+ "active" => "bg-emerald-100 text-emerald-800",
77
+ "draft" => "bg-amber-100 text-amber-800",
78
+ "retired" => "bg-slate-200 text-slate-600"
79
+ }
80
+ classes = "inline-flex rounded-full px-2 py-0.5 text-xs font-medium " \
81
+ "#{palette.fetch(status.to_s, "bg-slate-200 text-slate-600")}"
82
+ tag.span(status, class: classes)
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculatorEditor
4
+ class ApplicationJob < ActiveJob::Base
5
+ end
6
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculatorEditor
4
+ class ApplicationMailer < ActionMailer::Base
5
+ default from: "from@example.com"
6
+ layout "mailer"
7
+ end
8
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculatorEditor
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,64 @@
1
+ <%# The formula expression editor. NOT a rich text editor, and specifically not Lexxy —
2
+ docs/lexxy-decision.md §3 demonstrates the bug: Lexxy's value contract is HTML, `<` is a
3
+ Dentaku operator, and `salary <bonus` round-trips through an HTML parser as `salary `,
4
+ which still parses as valid Dentaku and quietly computes a different number.
5
+
6
+ So the field is a real <textarea> that keeps its `name` and `id`. The colour comes from a
7
+ <pre> sitting behind it: the textarea's own text is transparent (only its caret is
8
+ visible), the <pre> holds the same characters wrapped in coloured spans, and the two
9
+ scroll together. Nothing in the overlay can reach the submitted value.
10
+
11
+ Both elements MUST agree on font, size, line-height, padding, border width and wrapping,
12
+ or the coloured text drifts away from the caret. That pairing lives in
13
+ `.aac-expression__ink` / `.aac-expression__overlay` in the stylesheet — change one and
14
+ change the other. %>
15
+ <div class="aac-expression"
16
+ data-controller="expression-editor"
17
+ data-expression-editor-validate-url-value="<%= validations_path %>"
18
+ data-expression-editor-scope-value="<%= @formula.scope %>"
19
+ data-expression-editor-functions-value="<%= calculator_function_names.to_json %>">
20
+
21
+ <%= label_tag "version_expression", "Expression", class: label_class %>
22
+
23
+ <div class="aac-expression__shell">
24
+ <pre class="aac-expression__overlay" aria-hidden="true"
25
+ data-expression-editor-target="highlight"></pre>
26
+
27
+ <%= text_area_tag "version[expression]", value,
28
+ id: "version_expression",
29
+ rows: 3,
30
+ spellcheck: false,
31
+ autocomplete: "off",
32
+ autocapitalize: "off",
33
+ autocorrect: "off",
34
+ class: "aac-expression__ink",
35
+ role: "combobox",
36
+ aria: { expanded: "false", autocomplete: "list", controls: "version_expression_suggestions" },
37
+ data: {
38
+ editor_target: "expression",
39
+ expression_editor_target: "input",
40
+ action: "input->expression-editor#onInput " \
41
+ "scroll->expression-editor#syncScroll " \
42
+ "keydown->expression-editor#onKeydown " \
43
+ "blur->expression-editor#onBlur " \
44
+ "click->expression-editor#refreshSuggestions"
45
+ } %>
46
+
47
+ <ul class="aac-suggestions" role="listbox" hidden
48
+ id="version_expression_suggestions"
49
+ aria-label="Functions and declared variables"
50
+ data-expression-editor-target="suggestions"></ul>
51
+ </div>
52
+
53
+ <p class="<%= hint_class %>">
54
+ Operators <code>+ - * / % ^</code>, comparisons <code>&lt; &gt; &lt;= &gt;= == !=</code>,
55
+ <code>and</code> / <code>or</code>. Use <code>not(x)</code>, not <code>!x</code>, and
56
+ <code>^</code>, not <code>**</code>. Start typing a name for functions and declared
57
+ variables.
58
+ </p>
59
+
60
+ <%# aria-live so the debounced result is announced without moving focus. %>
61
+ <div class="aac-expression__status mt-2 space-y-1"
62
+ role="status" aria-live="polite"
63
+ data-expression-editor-target="status"></div>
64
+ </div>
@@ -0,0 +1,51 @@
1
+ <%# Index-keyed rather than `[]`-appended: Rack closes an array-of-hashes element when it
2
+ sees a repeated key, so a row whose only differing field is a checkbox can merge into
3
+ its neighbour. `CastVariableRows` accepts either shape; this one cannot go wrong.
4
+
5
+ Renaming a variable re-colours the expression above and re-runs the debounced check
6
+ without a reload. That is NOT wired with `data-action` here: these rows are a sibling
7
+ section of the form, not descendants of the `expression-editor` element, and a Stimulus
8
+ action only routes to a controller found on an ancestor — so a `data-action` written here
9
+ fires nothing at all. The controller listens for `input`/`change` on the whole form
10
+ instead; see its `connect`. %>
11
+ <fieldset class="variable-row grid gap-3 rounded-md border border-slate-200 bg-slate-50 p-3 sm:grid-cols-12">
12
+ <div class="sm:col-span-3">
13
+ <%= label_tag "version_variables_#{index}_name", "Name", class: label_class %>
14
+ <%= text_field_tag "version[variables][#{index}][name]", row["name"],
15
+ id: "version_variables_#{index}_name",
16
+ class: field_class("font-mono"),
17
+ autocomplete: "off",
18
+ spellcheck: false %>
19
+ </div>
20
+
21
+ <div class="sm:col-span-3">
22
+ <%= label_tag "version_variables_#{index}_source_type", "Source", class: label_class %>
23
+ <%= select_tag "version[variables][#{index}][source_type]",
24
+ options_for_select(ActsAsCalculator::ResolveVariables::SOURCE_TYPES.map(&:to_s),
25
+ row["source_type"].presence || "context"),
26
+ id: "version_variables_#{index}_source_type",
27
+ class: field_class %>
28
+ </div>
29
+
30
+ <div class="sm:col-span-4">
31
+ <%= label_tag "version_variables_#{index}_source_config", "source_config (JSON)", class: label_class %>
32
+ <%= text_area_tag "version[variables][#{index}][source_config]",
33
+ row["source_config"].presence && JSON.generate(row["source_config"]),
34
+ rows: 2, spellcheck: false, id: "version_variables_#{index}_source_config",
35
+ class: field_class("font-mono text-xs"),
36
+ placeholder: '{"table": "federal_2026", "using": "salary"}' %>
37
+ </div>
38
+
39
+ <div class="flex items-end sm:col-span-2">
40
+ <label class="flex items-center gap-2 pb-2 text-sm text-slate-700">
41
+ <%# The hidden field is what makes an unchecked box post "0" instead of nothing;
42
+ `CastVariableRows` is what turns that "0" into a real `false`, which matters because
43
+ the core gem treats anything that is not literally `false` as required. %>
44
+ <%= hidden_field_tag "version[variables][#{index}][required]", "0", id: nil %>
45
+ <%= check_box_tag "version[variables][#{index}][required]", "1", row.fetch("required", true),
46
+ id: "version_variables_#{index}_required",
47
+ class: "rounded border-slate-300" %>
48
+ Required
49
+ </label>
50
+ </div>
51
+ </fieldset>
@@ -0,0 +1,45 @@
1
+ <% content_for :title, "Versions of #{@formula.key}" %>
2
+
3
+ <%= render "acts_as_calculator_editor/shared/page_header",
4
+ back_label: "← #{@formula.key}", back_path: formula_path(@formula),
5
+ title: "Versions of #{@formula.key}",
6
+ subtitle: "Scope: #{@formula.scope}",
7
+ actions: link_to("Publish new version", new_formula_version_path(@formula), class: button_class(:primary)) %>
8
+
9
+ <div class="overflow-x-auto rounded-lg border border-slate-200 bg-white">
10
+ <table class="w-full text-sm">
11
+ <thead class="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
12
+ <tr>
13
+ <th class="px-4 py-2 font-medium">#</th>
14
+ <th class="px-4 py-2 font-medium">Status</th>
15
+ <th class="px-4 py-2 font-medium">Effective</th>
16
+ <th class="px-4 py-2 font-medium">Expression</th>
17
+ <th class="px-4 py-2 font-medium">Variables</th>
18
+ <th class="px-4 py-2"></th>
19
+ </tr>
20
+ </thead>
21
+ <tbody class="divide-y divide-slate-100">
22
+ <% @versions.each do |version| %>
23
+ <tr id="<%= dom_id(version) %>" class="hover:bg-slate-50">
24
+ <td class="px-4 py-2 text-slate-600"><%= version.version_number %></td>
25
+ <td class="px-4 py-2"><%= status_badge(version.status) %></td>
26
+ <td class="whitespace-nowrap px-4 py-2 font-mono text-xs text-slate-600">
27
+ <%= version.effective_from %> → <%= version.effective_to || "open" %>
28
+ </td>
29
+ <td class="px-4 py-2">
30
+ <code class="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs text-slate-800"><%= version.expression %></code>
31
+ </td>
32
+ <td class="px-4 py-2 font-mono text-xs text-slate-600"><%= version.variables.map(&:name).sort.join(", ") %></td>
33
+ <td class="whitespace-nowrap px-4 py-2 text-right">
34
+ <%= link_to "Show", formula_version_path(@formula, version), class: "font-medium text-slate-700 hover:text-slate-900" %>
35
+ <%= link_to "Duplicate", new_formula_version_path(@formula, from_version_id: version.id), class: "ml-3 font-medium text-slate-700 hover:text-slate-900" %>
36
+ </td>
37
+ </tr>
38
+ <% end %>
39
+ </tbody>
40
+ </table>
41
+
42
+ <% if @versions.empty? %>
43
+ <p class="px-4 py-3 text-sm text-slate-500">No versions yet.</p>
44
+ <% end %>
45
+ </div>
@@ -0,0 +1,80 @@
1
+ <% content_for :title, "Publish a version of #{@formula.key}" %>
2
+
3
+ <div class="mb-6">
4
+ <%= link_to "← #{@formula.key}", formula_path(@formula), class: "text-sm text-slate-500 hover:text-slate-900" %>
5
+ <h1 class="mt-1 text-2xl font-semibold tracking-tight">Publish a version of <%= @formula.key %></h1>
6
+ <p class="mt-1 text-sm text-slate-500">Scope: <code class="rounded bg-slate-100 px-1.5 py-0.5"><%= @formula.scope %></code></p>
7
+ </div>
8
+
9
+ <%= render "acts_as_calculator_editor/shared/error_message" %>
10
+
11
+ <%# The overlap rule is knowable from data already in hand, so it is shown before submit
12
+ rather than discovered by a 409: no two active versions of one formula may cover
13
+ overlapping dates. %>
14
+ <section class="active-ranges mb-6 rounded-lg border border-slate-200 bg-white p-4">
15
+ <h2 class="mb-2 text-sm font-semibold text-slate-900">Already in force</h2>
16
+ <% if @active_versions.empty? %>
17
+ <p class="text-sm text-slate-500">No active versions — any effective date is free.</p>
18
+ <% else %>
19
+ <ul class="mb-2 space-y-1 text-sm">
20
+ <% @active_versions.each do |version| %>
21
+ <li class="font-mono text-slate-700">
22
+ v<%= version.version_number %>: <%= version.effective_from %> → <%= version.effective_to || "open-ended" %>
23
+ </li>
24
+ <% end %>
25
+ </ul>
26
+ <p class="text-xs text-slate-500">Publishing an active version that starts inside one of these supersedes it. If that
27
+ would leave part of its range with no active version, the publish is refused and says
28
+ so.</p>
29
+ <% end %>
30
+ </section>
31
+
32
+ <%= form_with url: formula_versions_path(@formula), method: :post, scope: :version,
33
+ html: { class: "space-y-6" } do |form| %>
34
+ <div class="rounded-lg border border-slate-200 bg-white p-5">
35
+ <%= render "expression_field", value: @form["expression"] %>
36
+ </div>
37
+
38
+ <div class="grid gap-4 rounded-lg border border-slate-200 bg-white p-5 sm:grid-cols-2">
39
+ <div>
40
+ <%= form.label :effective_from, "Effective from", class: label_class %>
41
+ <%= form.date_field :effective_from, value: @form["effective_from"], required: true, class: field_class %>
42
+ </div>
43
+
44
+ <div>
45
+ <%= form.label :effective_to, "Effective to", class: label_class %>
46
+ <%= form.date_field :effective_to, value: @form["effective_to"], class: field_class %>
47
+ <span class="<%= hint_class %>">Blank = open-ended.</span>
48
+ </div>
49
+
50
+ <div>
51
+ <%= form.label :status, "Status", class: label_class %>
52
+ <%= form.select :status, ActsAsCalculator::FormulaVersion::STATUSES, { selected: @form["status"] }, class: field_class %>
53
+ </div>
54
+
55
+ <div>
56
+ <%= form.label :change_note, "Change note", class: label_class %>
57
+ <%= form.text_field :change_note, value: @form["change_note"], class: field_class,
58
+ placeholder: "Why this version exists" %>
59
+ </div>
60
+ </div>
61
+
62
+ <div class="rounded-lg border border-slate-200 bg-white p-5">
63
+ <h2 class="text-sm font-semibold text-slate-900">Variables</h2>
64
+ <p class="mb-4 mt-1 text-xs text-slate-500">Every identifier the expression uses must be declared here, or the formula raises
65
+ MissingVariableError when it runs. Names here feed the expression editor's highlighting
66
+ and autocomplete as you type.</p>
67
+
68
+ <div class="space-y-3">
69
+ <% rows = @variable_rows + Array.new(3) { { "name" => "", "source_type" => "context" } } %>
70
+ <% rows.each_with_index do |row, index| %>
71
+ <%= render "variable_fields", index: index, row: row %>
72
+ <% end %>
73
+ </div>
74
+ </div>
75
+
76
+ <div class="flex items-center gap-3">
77
+ <%= form.submit "Publish version", class: button_class(:primary) %>
78
+ <%= link_to "Cancel", formula_path(@formula), class: button_class(:secondary) %>
79
+ </div>
80
+ <% end %>
@@ -0,0 +1,75 @@
1
+ <% content_for :title, "#{@formula.key} v#{@version.version_number}" %>
2
+
3
+ <%= render "acts_as_calculator_editor/shared/page_header",
4
+ back_label: "← #{@formula.key}", back_path: formula_path(@formula),
5
+ title: "#{@formula.key} — version #{@version.version_number}",
6
+ actions: link_to("Duplicate into a new version",
7
+ new_formula_version_path(@formula, from_version_id: @version.id),
8
+ class: button_class(:primary)) %>
9
+
10
+ <%= render "acts_as_calculator_editor/shared/error_message" %>
11
+
12
+ <section class="mb-8 rounded-lg border border-slate-200 bg-white p-5">
13
+ <dl class="grid gap-x-6 gap-y-4 sm:grid-cols-2">
14
+ <div>
15
+ <dt class="text-xs uppercase tracking-wide text-slate-500">Status</dt>
16
+ <dd class="mt-1"><%= status_badge(@version.status) %></dd>
17
+ </div>
18
+ <div>
19
+ <dt class="text-xs uppercase tracking-wide text-slate-500">Effective</dt>
20
+ <dd class="mt-1 font-mono text-sm text-slate-800">
21
+ <%= @version.effective_from %> → <%= @version.effective_to || "open-ended" %>
22
+ </dd>
23
+ </div>
24
+ <div class="sm:col-span-2">
25
+ <dt class="text-xs uppercase tracking-wide text-slate-500">Change note</dt>
26
+ <dd class="mt-1 text-sm text-slate-800"><%= @version.change_note.presence || "—" %></dd>
27
+ </div>
28
+ <div class="sm:col-span-2">
29
+ <dt class="text-xs uppercase tracking-wide text-slate-500">Expression</dt>
30
+ <dd class="mt-1">
31
+ <pre class="overflow-x-auto rounded-md bg-slate-900 p-3 font-mono text-xs text-slate-100"><code><%= @version.expression %></code></pre>
32
+ </dd>
33
+ </div>
34
+ </dl>
35
+ </section>
36
+
37
+ <section class="mb-8 overflow-x-auto rounded-lg border border-slate-200 bg-white">
38
+ <header class="border-b border-slate-200 px-4 py-3">
39
+ <h2 class="text-sm font-semibold text-slate-900">Variables</h2>
40
+ </header>
41
+ <table class="w-full text-sm">
42
+ <thead class="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
43
+ <tr>
44
+ <th class="px-4 py-2 font-medium">Name</th>
45
+ <th class="px-4 py-2 font-medium">Source</th>
46
+ <th class="px-4 py-2 font-medium">Required</th>
47
+ <th class="px-4 py-2 font-medium">source_config</th>
48
+ </tr>
49
+ </thead>
50
+ <tbody class="divide-y divide-slate-100">
51
+ <% @variables.each do |variable| %>
52
+ <tr>
53
+ <td class="px-4 py-2 font-mono font-medium text-slate-900"><%= variable.name %></td>
54
+ <td class="px-4 py-2 text-slate-600"><%= variable.source_type %></td>
55
+ <td class="px-4 py-2 text-slate-600"><%= variable.required? %></td>
56
+ <td class="px-4 py-2">
57
+ <code class="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs text-slate-800"><%= JSON.generate(variable.source_config || {}) %></code>
58
+ </td>
59
+ </tr>
60
+ <% end %>
61
+ </tbody>
62
+ </table>
63
+
64
+ <% if @variables.empty? %>
65
+ <p class="px-4 py-3 text-sm text-slate-500">No variables declared.</p>
66
+ <% end %>
67
+ </section>
68
+
69
+ <%# No edit form, by design: `expression`, `effective_from` and `version_number` are
70
+ immutable once a version is active. Publishing a corrected version is the fix; the bad
71
+ one stays in the history where the audit trail expects it. %>
72
+ <p class="rounded-md border border-slate-200 bg-slate-100 px-4 py-3 text-sm text-slate-600">
73
+ Versions are immutable. To correct this one, duplicate it into a new version — the original
74
+ stays in the history the audit trail depends on.
75
+ </p>
@@ -0,0 +1,23 @@
1
+ <%# Identity only. There is deliberately no `expression` field here — a formula's maths
2
+ lives on its versions, and this form would be the wrong place to imply otherwise.
3
+ There is no owner field either; see ActsAsCalculatorEditor.owner_classes. %>
4
+ <%= form_with url: url, method: http_method, scope: :formula, html: { class: "space-y-6" } do |form| %>
5
+ <div class="grid gap-4 rounded-lg border border-slate-200 bg-white p-5 sm:grid-cols-2">
6
+ <div>
7
+ <%= form.label :key, "Key", class: label_class %>
8
+ <%= form.text_field :key, value: formula.key, required: true, class: field_class("font-mono") %>
9
+ </div>
10
+
11
+ <div>
12
+ <%= form.label :scope, "Scope", class: label_class %>
13
+ <%= form.text_field :scope, value: formula.scope, class: field_class("font-mono"),
14
+ placeholder: ActsAsCalculator::DEFAULT_SCOPE %>
15
+ <span class="<%= hint_class %>">Uniqueness is [key, scope, owner] — two formulas may share a key in different scopes.</span>
16
+ </div>
17
+ </div>
18
+
19
+ <div class="flex items-center gap-3">
20
+ <%= form.submit submit_label, class: button_class(:primary) %>
21
+ <%= link_to "Cancel", cancel_path, class: button_class(:secondary) %>
22
+ </div>
23
+ <% end %>
@@ -0,0 +1,11 @@
1
+ <% content_for :title, "Edit formula" %>
2
+
3
+ <%= render "acts_as_calculator_editor/shared/page_header",
4
+ back_label: "← #{@formula.key}", back_path: formula_path(@formula),
5
+ title: "Edit #{@formula.key}",
6
+ subtitle: "Identity only: renaming or re-scoping silently re-targets every future calculation of that key, so this form changes nothing about its maths." %>
7
+
8
+ <%= render "acts_as_calculator_editor/shared/error_message" %>
9
+
10
+ <%= render "form", formula: @formula, url: formula_path(@formula), http_method: :patch,
11
+ submit_label: "Save formula", cancel_path: formula_path(@formula) %>