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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +107 -0
- data/Rakefile +8 -0
- data/app/assets/javascripts/acts_as_calculator_editor/application.js +26 -0
- data/app/assets/javascripts/acts_as_calculator_editor/dentaku_tokenizer.js +139 -0
- data/app/assets/javascripts/acts_as_calculator_editor/expression_editor_controller.js +380 -0
- data/app/assets/javascripts/acts_as_calculator_editor/list_filter_controller.js +39 -0
- data/app/assets/javascripts/acts_as_calculator_editor/template_editor_controller.js +86 -0
- data/app/assets/stylesheets/acts_as_calculator_editor/application.css +2 -0
- data/app/controllers/acts_as_calculator_editor/application_controller.rb +115 -0
- data/app/controllers/acts_as_calculator_editor/exports_controller.rb +41 -0
- data/app/controllers/acts_as_calculator_editor/formula_versions_controller.rb +128 -0
- data/app/controllers/acts_as_calculator_editor/formulas_controller.rb +94 -0
- data/app/controllers/acts_as_calculator_editor/lookup_tables_controller.rb +112 -0
- data/app/controllers/acts_as_calculator_editor/templates_controller.rb +130 -0
- data/app/controllers/acts_as_calculator_editor/validations_controller.rb +23 -0
- data/app/helpers/acts_as_calculator_editor/application_helper.rb +85 -0
- data/app/jobs/acts_as_calculator_editor/application_job.rb +6 -0
- data/app/mailers/acts_as_calculator_editor/application_mailer.rb +8 -0
- data/app/models/acts_as_calculator_editor/application_record.rb +7 -0
- data/app/views/acts_as_calculator_editor/formula_versions/_expression_field.html.erb +64 -0
- data/app/views/acts_as_calculator_editor/formula_versions/_variable_fields.html.erb +51 -0
- data/app/views/acts_as_calculator_editor/formula_versions/index.html.erb +45 -0
- data/app/views/acts_as_calculator_editor/formula_versions/new.html.erb +80 -0
- data/app/views/acts_as_calculator_editor/formula_versions/show.html.erb +75 -0
- data/app/views/acts_as_calculator_editor/formulas/_form.html.erb +23 -0
- data/app/views/acts_as_calculator_editor/formulas/edit.html.erb +11 -0
- data/app/views/acts_as_calculator_editor/formulas/index.html.erb +71 -0
- data/app/views/acts_as_calculator_editor/formulas/new.html.erb +10 -0
- data/app/views/acts_as_calculator_editor/formulas/show.html.erb +72 -0
- data/app/views/acts_as_calculator_editor/lookup_tables/_entry_fields.html.erb +24 -0
- data/app/views/acts_as_calculator_editor/lookup_tables/_form.html.erb +47 -0
- data/app/views/acts_as_calculator_editor/lookup_tables/edit.html.erb +12 -0
- data/app/views/acts_as_calculator_editor/lookup_tables/index.html.erb +67 -0
- data/app/views/acts_as_calculator_editor/lookup_tables/new.html.erb +11 -0
- data/app/views/acts_as_calculator_editor/lookup_tables/show.html.erb +71 -0
- data/app/views/acts_as_calculator_editor/shared/_error_message.html.erb +12 -0
- data/app/views/acts_as_calculator_editor/shared/_export_panel.html.erb +52 -0
- data/app/views/acts_as_calculator_editor/shared/_flash.html.erb +8 -0
- data/app/views/acts_as_calculator_editor/shared/_page_header.html.erb +16 -0
- data/app/views/acts_as_calculator_editor/shared/error.html.erb +11 -0
- data/app/views/acts_as_calculator_editor/templates/_form.html.erb +124 -0
- data/app/views/acts_as_calculator_editor/templates/_lexxy_toolbar.html.erb +101 -0
- data/app/views/acts_as_calculator_editor/templates/index.html.erb +83 -0
- data/app/views/acts_as_calculator_editor/templates/new.html.erb +13 -0
- data/app/views/acts_as_calculator_editor/templates/preview.html.erb +29 -0
- data/app/views/acts_as_calculator_editor/templates/show.html.erb +111 -0
- data/app/views/layouts/acts_as_calculator_editor/application.html.erb +52 -0
- data/config/importmap.rb +13 -0
- data/config/routes.rb +46 -0
- data/lib/acts_as_calculator_editor/build_export_document.rb +37 -0
- data/lib/acts_as_calculator_editor/build_sample_result.rb +26 -0
- data/lib/acts_as_calculator_editor/cast_entry_rows.rb +52 -0
- data/lib/acts_as_calculator_editor/cast_export_decimal.rb +19 -0
- data/lib/acts_as_calculator_editor/cast_variable_rows.rb +90 -0
- data/lib/acts_as_calculator_editor/delete_lookup_table.rb +54 -0
- data/lib/acts_as_calculator_editor/engine.rb +32 -0
- data/lib/acts_as_calculator_editor/export_formulas.rb +89 -0
- data/lib/acts_as_calculator_editor/export_lookup_tables.rb +49 -0
- data/lib/acts_as_calculator_editor/export_owner_reference.rb +18 -0
- data/lib/acts_as_calculator_editor/export_templates.rb +51 -0
- data/lib/acts_as_calculator_editor/publish_lookup_table.rb +53 -0
- data/lib/acts_as_calculator_editor/render_template_preview.rb +60 -0
- data/lib/acts_as_calculator_editor/validate_expression.rb +127 -0
- data/lib/acts_as_calculator_editor/version.rb +5 -0
- data/lib/acts_as_calculator_editor.rb +76 -0
- data/lib/tasks/acts_as_calculator_editor_tasks.rake +6 -0
- 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,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>< > <= >= == !=</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) %>
|