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,71 @@
1
+ <% content_for :title, "Formulas" %>
2
+
3
+ <%= render "acts_as_calculator_editor/shared/page_header",
4
+ title: "Formulas",
5
+ subtitle: "A formula is an identity; its maths lives on effective-dated versions.",
6
+ actions: link_to("New formula", new_formula_path, class: button_class(:primary)) %>
7
+
8
+ <div data-controller="list-filter" class="rounded-lg border border-slate-200 bg-white">
9
+ <div class="flex flex-wrap items-end gap-3 border-b border-slate-200 p-4">
10
+ <%# The server-side filter is the real one — it is what a bookmarked URL carries, and the
11
+ only one that sees rows this page did not render. %>
12
+ <%= form_with url: formulas_path, method: :get, class: "flex flex-wrap items-end gap-3" do |form| %>
13
+ <div>
14
+ <%= form.label :key, "Key", class: label_class %>
15
+ <%= form.text_field :key, value: params[:key], class: field_class("w-40 font-mono") %>
16
+ </div>
17
+ <div>
18
+ <%= form.label :scope, "Scope", class: label_class %>
19
+ <%= form.text_field :scope, value: params[:scope], class: field_class("w-40 font-mono") %>
20
+ </div>
21
+ <%= form.submit "Filter", class: button_class(:secondary) %>
22
+ <% end %>
23
+
24
+ <div class="ml-auto">
25
+ <%= label_tag :formula_quick_filter, "Narrow this page", class: label_class %>
26
+ <%= text_field_tag :formula_quick_filter, nil,
27
+ class: field_class("w-56"),
28
+ placeholder: "type to filter rows",
29
+ autocomplete: "off",
30
+ data: { list_filter_target: "query", action: "input->list-filter#filter" } %>
31
+ </div>
32
+ </div>
33
+
34
+ <div class="overflow-x-auto">
35
+ <table class="w-full text-sm">
36
+ <thead class="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
37
+ <tr>
38
+ <%# Scope is a first-class column, never decoration: uniqueness is scoped to
39
+ [key, scope, owner], so two rows may legitimately read "net_pay". %>
40
+ <th class="px-4 py-2 font-medium">Key</th>
41
+ <th class="px-4 py-2 font-medium">Scope</th>
42
+ <th class="px-4 py-2 font-medium">Owner</th>
43
+ <th class="px-4 py-2 font-medium">Versions</th>
44
+ <th class="px-4 py-2"></th>
45
+ </tr>
46
+ </thead>
47
+ <tbody class="divide-y divide-slate-100">
48
+ <% @formulas.each do |formula| %>
49
+ <tr id="<%= dom_id(formula) %>" class="hover:bg-slate-50"
50
+ data-list-filter-target="row">
51
+ <td class="px-4 py-2 font-mono font-medium text-slate-900"><%= formula.key %></td>
52
+ <td class="px-4 py-2 font-mono text-slate-600"><%= formula.scope %></td>
53
+ <td class="px-4 py-2 text-slate-600"><%= formula.owner_type ? "#{formula.owner_type}##{formula.owner_id}" : "global" %></td>
54
+ <td class="px-4 py-2 text-slate-600"><%= formula.versions.size %></td>
55
+ <td class="px-4 py-2 text-right">
56
+ <%= link_to "Show", formula_path(formula), class: "font-medium text-slate-700 hover:text-slate-900" %>
57
+ </td>
58
+ </tr>
59
+ <% end %>
60
+ </tbody>
61
+ </table>
62
+ </div>
63
+
64
+ <p class="px-4 py-3 text-sm text-slate-500" data-list-filter-target="empty" hidden>
65
+ No formulas match that filter.
66
+ </p>
67
+
68
+ <% if @formulas.empty? %>
69
+ <p class="px-4 py-3 text-sm text-slate-500">No formulas yet.</p>
70
+ <% end %>
71
+ </div>
@@ -0,0 +1,10 @@
1
+ <% content_for :title, "New formula" %>
2
+
3
+ <%= render "acts_as_calculator_editor/shared/page_header",
4
+ back_label: "← Formulas", back_path: formulas_path,
5
+ title: "New formula" %>
6
+
7
+ <%= render "acts_as_calculator_editor/shared/error_message" %>
8
+
9
+ <%= render "form", formula: @formula, url: formulas_path, http_method: :post,
10
+ submit_label: "Create formula", cancel_path: formulas_path %>
@@ -0,0 +1,72 @@
1
+ <% content_for :title, "#{@formula.key} (#{@formula.scope})" %>
2
+
3
+ <%= render "acts_as_calculator_editor/shared/page_header",
4
+ back_label: "← Formulas", back_path: formulas_path,
5
+ title: @formula.key,
6
+ subtitle: "Scope: #{@formula.scope} · Owner: #{@formula.owner_type ? "#{@formula.owner_type}##{@formula.owner_id}" : 'global'}",
7
+ actions: capture {
8
+ concat link_to("Edit identity", edit_formula_path(@formula), class: button_class(:secondary))
9
+ concat link_to("Publish new version", new_formula_version_path(@formula), class: button_class(:primary))
10
+ } %>
11
+
12
+ <%= render "acts_as_calculator_editor/shared/error_message" %>
13
+
14
+ <section class="mb-8 rounded-lg border border-slate-200 bg-white">
15
+ <header class="border-b border-slate-200 px-4 py-3">
16
+ <h2 class="text-sm font-semibold text-slate-900">Versions</h2>
17
+ </header>
18
+
19
+ <%# There is no "edit version" link, and there never should be: once a version is active its
20
+ expression, dates and number are immutable. "Duplicate" opens a new-version form
21
+ prefilled from this one, which is what an operator reaching for edit actually wants. %>
22
+ <div class="overflow-x-auto">
23
+ <table class="w-full text-sm">
24
+ <thead class="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
25
+ <tr>
26
+ <th class="px-4 py-2 font-medium">#</th>
27
+ <th class="px-4 py-2 font-medium">Status</th>
28
+ <th class="px-4 py-2 font-medium">Effective</th>
29
+ <th class="px-4 py-2 font-medium">Expression</th>
30
+ <th class="px-4 py-2"></th>
31
+ </tr>
32
+ </thead>
33
+ <tbody class="divide-y divide-slate-100">
34
+ <% @versions.each do |version| %>
35
+ <tr id="<%= dom_id(version) %>" class="hover:bg-slate-50">
36
+ <td class="px-4 py-2 text-slate-600"><%= version.version_number %></td>
37
+ <td class="px-4 py-2"><%= status_badge(version.status) %></td>
38
+ <td class="px-4 py-2 whitespace-nowrap font-mono text-xs text-slate-600">
39
+ <%= version.effective_from %> → <%= version.effective_to || "open" %>
40
+ </td>
41
+ <td class="px-4 py-2">
42
+ <code class="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs text-slate-800"><%= version.expression %></code>
43
+ </td>
44
+ <td class="whitespace-nowrap px-4 py-2 text-right">
45
+ <%= link_to "Show", formula_version_path(@formula, version), class: "font-medium text-slate-700 hover:text-slate-900" %>
46
+ <%= 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" %>
47
+ </td>
48
+ </tr>
49
+ <% end %>
50
+ </tbody>
51
+ </table>
52
+ </div>
53
+
54
+ <% if @versions.empty? %>
55
+ <p class="px-4 py-3 text-sm text-slate-500">No versions yet — this formula cannot calculate until one is published.</p>
56
+ <% end %>
57
+ </section>
58
+
59
+ <section class="rounded-lg border border-red-200 bg-red-50 p-4">
60
+ <h2 class="text-sm font-semibold text-red-900">Delete</h2>
61
+
62
+ <% if @versions.any? { |version| version.runs.exists? } %>
63
+ <%# Said before the click, not after the 409: this delete was always going to fail. %>
64
+ <p class="mt-1 text-sm text-red-800">This formula has versions with recorded runs, so it cannot be deleted — the audit trail
65
+ is never removed to make room for a delete. Retire its active version instead.</p>
66
+ <% else %>
67
+ <p class="mt-1 mb-3 text-sm text-red-800">Deletes the formula and every version of it.</p>
68
+ <%= button_to "Delete formula", formula_path(@formula), method: :delete,
69
+ class: button_class(:danger),
70
+ form: { data: { turbo_confirm: "Delete #{@formula.key} and all its versions?" } } %>
71
+ <% end %>
72
+ </section>
@@ -0,0 +1,24 @@
1
+ <%# `from` and `to` are nullable on both ends — blank means unbounded, not zero — so these
2
+ are text fields rather than number fields with a default. %>
3
+ <fieldset class="entry-row grid gap-3 rounded-md border border-slate-200 bg-slate-50 p-3 sm:grid-cols-3">
4
+ <div>
5
+ <%= label_tag "lookup_table_entries_#{index}_from", "From", class: label_class %>
6
+ <%= text_field_tag "lookup_table[entries][#{index}][from]", row["from"],
7
+ id: "lookup_table_entries_#{index}_from", placeholder: "unbounded",
8
+ class: field_class("font-mono"), autocomplete: "off" %>
9
+ </div>
10
+
11
+ <div>
12
+ <%= label_tag "lookup_table_entries_#{index}_to", "To", class: label_class %>
13
+ <%= text_field_tag "lookup_table[entries][#{index}][to]", row["to"],
14
+ id: "lookup_table_entries_#{index}_to", placeholder: "unbounded",
15
+ class: field_class("font-mono"), autocomplete: "off" %>
16
+ </div>
17
+
18
+ <div>
19
+ <%= label_tag "lookup_table_entries_#{index}_value", "Value", class: label_class %>
20
+ <%= text_field_tag "lookup_table[entries][#{index}][value]", row["value"],
21
+ id: "lookup_table_entries_#{index}_value",
22
+ class: field_class("font-mono"), autocomplete: "off" %>
23
+ </div>
24
+ </fieldset>
@@ -0,0 +1,47 @@
1
+ <%= form_with url: url, method: http_method, html: { class: "space-y-6" } do |form| %>
2
+ <div class="grid gap-4 rounded-lg border border-slate-200 bg-white p-5 sm:grid-cols-2">
3
+ <div>
4
+ <%= label_tag "lookup_table_key", "Key", class: label_class %>
5
+ <%= text_field_tag "lookup_table[key]", form_values["key"], id: "lookup_table_key",
6
+ required: true, readonly: locked, class: field_class("font-mono") %>
7
+ </div>
8
+
9
+ <div>
10
+ <%= label_tag "lookup_table_scope", "Scope", class: label_class %>
11
+ <%= text_field_tag "lookup_table[scope]", form_values["scope"], id: "lookup_table_scope",
12
+ readonly: locked, class: field_class("font-mono") %>
13
+ </div>
14
+
15
+ <% if locked %>
16
+ <%# Key and scope are the table's identity, and a formula variable names its table by
17
+ key. Changing either here would create a second table and leave every formula still
18
+ reading the old one — so an edit changes brackets only, and a new key is a new table. %>
19
+ <p class="hint sm:col-span-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
20
+ Key and scope cannot be changed. A formula variable names its table by
21
+ key, so renaming would leave those formulas reading the old table. Create a new table
22
+ (the convention is <code class="rounded bg-amber-100 px-1">key_v2</code>) and publish a
23
+ formula version pointing at it.
24
+ </p>
25
+ <% end %>
26
+ </div>
27
+
28
+ <div class="rounded-lg border border-slate-200 bg-white p-5">
29
+ <h2 class="text-sm font-semibold text-slate-900">Brackets</h2>
30
+ <p class="mb-4 mt-1 text-xs text-slate-500">Order is the order below, and that order becomes
31
+ <code class="rounded bg-slate-100 px-1">position</code>. Leave a bound blank for
32
+ unbounded — blank means unbounded, not zero. A row with all three fields blank is
33
+ dropped.</p>
34
+
35
+ <div class="space-y-3">
36
+ <% rows = entry_rows + Array.new(3) { { "from" => "", "to" => "", "value" => "" } } %>
37
+ <% rows.each_with_index do |row, index| %>
38
+ <%= render "entry_fields", index: index, row: row %>
39
+ <% end %>
40
+ </div>
41
+ </div>
42
+
43
+ <div class="flex items-center gap-3">
44
+ <%= form.submit submit_label, class: button_class(:primary) %>
45
+ <%= link_to "Cancel", cancel_path, class: button_class(:secondary) %>
46
+ </div>
47
+ <% end %>
@@ -0,0 +1,12 @@
1
+ <% content_for :title, "Edit #{@lookup_table.key}" %>
2
+
3
+ <%= render "acts_as_calculator_editor/shared/page_header",
4
+ back_label: "← #{@lookup_table.key}", back_path: lookup_table_path(@lookup_table),
5
+ title: "Edit #{@lookup_table.key}",
6
+ subtitle: "Brackets only — key and scope are the table's identity." %>
7
+
8
+ <%= render "acts_as_calculator_editor/shared/error_message" %>
9
+
10
+ <%= render "form", form_values: @form, entry_rows: @entry_rows, locked: true,
11
+ url: lookup_table_path(@lookup_table), http_method: :patch,
12
+ submit_label: "Save brackets", cancel_path: lookup_table_path(@lookup_table) %>
@@ -0,0 +1,67 @@
1
+ <% content_for :title, "Lookup tables" %>
2
+
3
+ <%= render "acts_as_calculator_editor/shared/page_header",
4
+ title: "Lookup tables",
5
+ subtitle: "Bracket tables a formula variable with source_type \"lookup\" resolves against.",
6
+ actions: link_to("New lookup table", new_lookup_table_path, class: button_class(:primary)) %>
7
+
8
+ <div data-controller="list-filter" class="rounded-lg border border-slate-200 bg-white">
9
+ <div class="flex flex-wrap items-end gap-3 border-b border-slate-200 p-4">
10
+ <%= form_with url: lookup_tables_path, method: :get, class: "flex flex-wrap items-end gap-3" do |form| %>
11
+ <div>
12
+ <%= form.label :key, "Key", class: label_class %>
13
+ <%= form.text_field :key, value: params[:key], class: field_class("w-40 font-mono") %>
14
+ </div>
15
+ <div>
16
+ <%= form.label :scope, "Scope", class: label_class %>
17
+ <%= form.text_field :scope, value: params[:scope], class: field_class("w-40 font-mono") %>
18
+ </div>
19
+ <%= form.submit "Filter", class: button_class(:secondary) %>
20
+ <% end %>
21
+
22
+ <div class="ml-auto">
23
+ <%= label_tag :lookup_quick_filter, "Narrow this page", class: label_class %>
24
+ <%= text_field_tag :lookup_quick_filter, nil,
25
+ class: field_class("w-56"),
26
+ placeholder: "type to filter rows",
27
+ autocomplete: "off",
28
+ data: { list_filter_target: "query", action: "input->list-filter#filter" } %>
29
+ </div>
30
+ </div>
31
+
32
+ <div class="overflow-x-auto">
33
+ <table class="w-full text-sm">
34
+ <thead class="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
35
+ <tr>
36
+ <th class="px-4 py-2 font-medium">Key</th>
37
+ <th class="px-4 py-2 font-medium">Scope</th>
38
+ <th class="px-4 py-2 font-medium">Owner</th>
39
+ <th class="px-4 py-2 font-medium">Brackets</th>
40
+ <th class="px-4 py-2"></th>
41
+ </tr>
42
+ </thead>
43
+ <tbody class="divide-y divide-slate-100">
44
+ <% @lookup_tables.each do |table| %>
45
+ <tr id="<%= dom_id(table) %>" class="hover:bg-slate-50"
46
+ data-list-filter-target="row">
47
+ <td class="px-4 py-2 font-mono font-medium text-slate-900"><%= table.key %></td>
48
+ <td class="px-4 py-2 font-mono text-slate-600"><%= table.scope %></td>
49
+ <td class="px-4 py-2 text-slate-600"><%= table.owner_type ? "#{table.owner_type}##{table.owner_id}" : "global" %></td>
50
+ <td class="px-4 py-2 text-slate-600"><%= table.entries.size %></td>
51
+ <td class="px-4 py-2 text-right">
52
+ <%= link_to "Show", lookup_table_path(table), class: "font-medium text-slate-700 hover:text-slate-900" %>
53
+ </td>
54
+ </tr>
55
+ <% end %>
56
+ </tbody>
57
+ </table>
58
+ </div>
59
+
60
+ <p class="px-4 py-3 text-sm text-slate-500" data-list-filter-target="empty" hidden>
61
+ No lookup tables match that filter.
62
+ </p>
63
+
64
+ <% if @lookup_tables.empty? %>
65
+ <p class="px-4 py-3 text-sm text-slate-500">No lookup tables yet. A formula variable with source_type "lookup" cannot evaluate without one.</p>
66
+ <% end %>
67
+ </div>
@@ -0,0 +1,11 @@
1
+ <% content_for :title, "New lookup table" %>
2
+
3
+ <%= render "acts_as_calculator_editor/shared/page_header",
4
+ back_label: "← Lookup tables", back_path: lookup_tables_path,
5
+ title: "New lookup table" %>
6
+
7
+ <%= render "acts_as_calculator_editor/shared/error_message" %>
8
+
9
+ <%= render "form", form_values: @form, entry_rows: @entry_rows, locked: false,
10
+ url: lookup_tables_path, http_method: :post,
11
+ submit_label: "Create lookup table", cancel_path: lookup_tables_path %>
@@ -0,0 +1,71 @@
1
+ <% content_for :title, "#{@lookup_table.key} (#{@lookup_table.scope})" %>
2
+
3
+ <%= render "acts_as_calculator_editor/shared/page_header",
4
+ back_label: "← Lookup tables", back_path: lookup_tables_path,
5
+ title: @lookup_table.key,
6
+ subtitle: "Scope: #{@lookup_table.scope} · Owner: #{@lookup_table.owner_type ? "#{@lookup_table.owner_type}##{@lookup_table.owner_id}" : 'global'}",
7
+ actions: link_to("Edit brackets", edit_lookup_table_path(@lookup_table), class: button_class(:primary)) %>
8
+
9
+ <%= render "acts_as_calculator_editor/shared/error_message" %>
10
+
11
+ <section class="mb-8 overflow-x-auto rounded-lg border border-slate-200 bg-white">
12
+ <header class="border-b border-slate-200 px-4 py-3">
13
+ <h2 class="text-sm font-semibold text-slate-900">Brackets</h2>
14
+ </header>
15
+ <table class="w-full text-sm">
16
+ <thead class="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
17
+ <tr>
18
+ <th class="px-4 py-2 font-medium">#</th>
19
+ <th class="px-4 py-2 font-medium">From</th>
20
+ <th class="px-4 py-2 font-medium">To</th>
21
+ <th class="px-4 py-2 font-medium">Value</th>
22
+ </tr>
23
+ </thead>
24
+ <tbody class="divide-y divide-slate-100">
25
+ <% @entries.each do |entry| %>
26
+ <tr>
27
+ <td class="px-4 py-2 text-slate-500"><%= entry.position %></td>
28
+ <td class="px-4 py-2 font-mono text-slate-800"><%= entry.from.nil? ? "unbounded" : entry.from.to_s("F") %></td>
29
+ <td class="px-4 py-2 font-mono text-slate-800"><%= entry.to.nil? ? "unbounded" : entry.to.to_s("F") %></td>
30
+ <td class="px-4 py-2 font-mono font-medium text-slate-900"><%= entry.value.to_s("F") %></td>
31
+ </tr>
32
+ <% end %>
33
+ </tbody>
34
+ </table>
35
+
36
+ <% if @entries.empty? %>
37
+ <p class="px-4 py-3 text-sm text-slate-500">No brackets yet.</p>
38
+ <% end %>
39
+ </section>
40
+
41
+ <section class="mb-8 rounded-lg border border-slate-200 bg-white p-4">
42
+ <h2 class="mb-2 text-sm font-semibold text-slate-900">Used by</h2>
43
+ <%# Which formula versions would resolve to this table if they ran now — owner shadowing
44
+ included. An active or retired one here means both editing the brackets and deleting the
45
+ table are refused, and this is where the operator finds that out. %>
46
+ <% if @used_by.empty? %>
47
+ <p class="text-sm text-slate-500">No formula version resolves to this table.</p>
48
+ <% else %>
49
+ <ul class="space-y-1 text-sm">
50
+ <% @used_by.each do |version| %>
51
+ <li>
52
+ <%= link_to "#{version.formula.key} v#{version.version_number}",
53
+ formula_version_path(version.formula, version),
54
+ class: "font-mono font-medium text-slate-800 hover:text-slate-950 underline" %>
55
+ <%= status_badge(version.status) %>
56
+ </li>
57
+ <% end %>
58
+ </ul>
59
+ <% end %>
60
+ </section>
61
+
62
+ <section class="rounded-lg border border-red-200 bg-red-50 p-4">
63
+ <h2 class="text-sm font-semibold text-red-900">Delete</h2>
64
+ <p class="mt-1 mb-3 text-sm text-red-800">
65
+ Refused with a 409 if any active or retired formula version resolves to this table — the
66
+ brackets past runs were audited against are not removed to make room for a delete.
67
+ </p>
68
+ <%= button_to "Delete lookup table", lookup_table_path(@lookup_table), method: :delete,
69
+ class: button_class(:danger),
70
+ form: { data: { turbo_confirm: "Delete #{@lookup_table.key}?" } } %>
71
+ </section>
@@ -0,0 +1,12 @@
1
+ <%# `PartialSupersedeError` and `LookupTableInUseError` are shown verbatim on purpose: their
2
+ messages name the exact date range that would be orphaned, or the formula versions
3
+ blocking an edit, and both suggest a fix. Replacing them with "Something went wrong"
4
+ throws away the only part of the failure an operator can act on.
5
+
6
+ `whitespace-pre-line` because those messages are multi-line and the line breaks are where
7
+ the list of blocking versions lives. %>
8
+ <% if @error_message.present? %>
9
+ <div class="editor-error mb-6 rounded-md border border-red-200 bg-red-50 px-4 py-3" role="alert">
10
+ <p class="whitespace-pre-line text-sm text-red-800"><%= @error_message %></p>
11
+ </div>
12
+ <% end %>
@@ -0,0 +1,52 @@
1
+ <%# GET /export — an import-shaped JSON document (_workspace/03_rails_report.md §3, §4).
2
+
3
+ A plain <form method="get">, not a Turbo Stream and not a fetch: the response is a file
4
+ download, and Turbo has no way to render one. `data-turbo="false"` keeps Turbo Drive from
5
+ intercepting the navigation, which would otherwise leave the operator on the page with
6
+ nothing saved. A GET form serialises its fields into the query string, which is exactly
7
+ the shape the endpoint documents. %>
8
+ <details class="relative">
9
+ <summary class="<%= button_class(:secondary) %> cursor-pointer list-none">Export JSON</summary>
10
+
11
+ <div class="absolute right-0 z-20 mt-2 w-80 rounded-lg border border-slate-200 bg-white p-4 shadow-lg">
12
+ <%= form_with url: export_path, method: :get, data: { turbo: false } do |form| %>
13
+ <p class="mb-3 text-xs text-slate-500">
14
+ Downloads lookup tables, formulas and templates in the order
15
+ <code class="rounded bg-slate-100 px-1">ImportDefinitions</code> applies them, so the
16
+ file re-imports cleanly.
17
+ </p>
18
+
19
+ <div class="mb-3">
20
+ <%= form.label :key, "Key (optional)", class: label_class %>
21
+ <%= form.text_field :key, value: params[:key], class: field_class, placeholder: "all keys" %>
22
+ </div>
23
+
24
+ <div class="mb-3">
25
+ <%= form.label :scope, "Scope (optional)", class: label_class %>
26
+ <%= form.text_field :scope, value: params[:scope], class: field_class, placeholder: "all scopes" %>
27
+ </div>
28
+
29
+ <div class="mb-3">
30
+ <%= form.label :disposition, "Delivery", class: label_class %>
31
+ <%= form.select :disposition,
32
+ [ [ "Download the file", "attachment" ], [ "Open in a browser tab", "inline" ] ],
33
+ {}, class: field_class %>
34
+ </div>
35
+
36
+ <label class="mb-3 flex items-start gap-2 text-sm text-slate-700">
37
+ <%= form.check_box :full_history, { class: "mt-0.5 rounded border-slate-300" }, "true", nil %>
38
+ <span>
39
+ Every formula version
40
+ <%# Flagged in the Rails report §7.2 and worth surfacing here rather than letting an
41
+ operator infer it: the flag widens the formulas section only. %>
42
+ <span class="<%= hint_class %>">
43
+ Templates always export their current version only — replaying a full template
44
+ history would undo a rollback on re-import.
45
+ </span>
46
+ </span>
47
+ </label>
48
+
49
+ <%= form.submit "Export", class: "#{button_class(:primary)} w-full" %>
50
+ <% end %>
51
+ </div>
52
+ </details>
@@ -0,0 +1,8 @@
1
+ <% if flash.any? %>
2
+ <div class="mx-auto max-w-6xl px-6 pt-4">
3
+ <% flash.each do |kind, message| %>
4
+ <% tone = kind.to_s == "alert" ? "border-red-200 bg-red-50 text-red-800" : "border-emerald-200 bg-emerald-50 text-emerald-800" %>
5
+ <p class="flash flash-<%= kind %> mb-2 rounded-md border px-4 py-3 text-sm <%= tone %>" role="status"><%= message %></p>
6
+ <% end %>
7
+ </div>
8
+ <% end %>
@@ -0,0 +1,16 @@
1
+ <%# Shared page heading: title, optional subtitle, optional back link, optional action slot. %>
2
+ <div class="mb-6 flex flex-wrap items-end justify-between gap-4">
3
+ <div>
4
+ <% if local_assigns[:back_label].present? %>
5
+ <%= link_to back_label, back_path, class: "text-sm text-slate-500 hover:text-slate-900" %>
6
+ <% end %>
7
+ <h1 class="mt-1 text-2xl font-semibold tracking-tight text-slate-900"><%= title %></h1>
8
+ <% if local_assigns[:subtitle].present? %>
9
+ <p class="mt-1 text-sm text-slate-500"><%= subtitle %></p>
10
+ <% end %>
11
+ </div>
12
+
13
+ <% if local_assigns[:actions].present? %>
14
+ <div class="flex items-center gap-2"><%= actions %></div>
15
+ <% end %>
16
+ </div>
@@ -0,0 +1,11 @@
1
+ <% content_for :title, "Calculator editor — error" %>
2
+
3
+ <h1 class="mb-6 text-2xl font-semibold tracking-tight">That didn't work</h1>
4
+
5
+ <%= render "acts_as_calculator_editor/shared/error_message" %>
6
+
7
+ <% if @error_type.present? %>
8
+ <p class="editor-error-type mb-6 font-mono text-xs text-slate-500"><%= @error_type %></p>
9
+ <% end %>
10
+
11
+ <p><%= link_to "Back to formulas", formulas_path, class: button_class(:secondary) %></p>
@@ -0,0 +1,124 @@
1
+ <%# Both body editors are rendered, and the `template-editor` controller decides which one is
2
+ visible and which one carries `name="template[body]"` — see the controller for why
3
+ swapping the name (rather than `disabled`) is what keeps the browser from submitting two
4
+ values. docs/lexxy-decision.md §4b: a `text` template's body is not HTML, so it keeps a
5
+ plain textarea; forcing Lexxy onto it would be the same HTML round-trip mistake the
6
+ formula field exists to avoid. %>
7
+ <%= form_with url: templates_path, method: :post, scope: :template,
8
+ html: { class: "space-y-6" },
9
+ data: {
10
+ controller: "template-editor",
11
+ template_editor_auto_preview_value: "true"
12
+ } do |form| %>
13
+
14
+ <div class="grid gap-4 rounded-lg border border-slate-200 bg-white p-5 sm:grid-cols-3">
15
+ <div>
16
+ <%= form.label :key, "Key", class: label_class %>
17
+ <%= form.text_field :key, value: form_values["key"], required: true, class: field_class("font-mono") %>
18
+ </div>
19
+
20
+ <div>
21
+ <%= form.label :scope, "Scope", class: label_class %>
22
+ <%= form.text_field :scope, value: form_values["scope"], class: field_class("font-mono"),
23
+ placeholder: ActsAsCalculator::DEFAULT_SCOPE %>
24
+ </div>
25
+
26
+ <div>
27
+ <%= form.label :format, "Format", class: label_class %>
28
+ <%= form.select :format, ActsAsCalculator::Template::FORMATS,
29
+ { selected: form_values["format"] },
30
+ class: field_class,
31
+ data: {
32
+ template_editor_target: "format",
33
+ action: "change->template-editor#formatChanged"
34
+ } %>
35
+ <span class="<%= hint_class %>">
36
+ <strong>html</strong> gets the rich text editor. <strong>text</strong> keeps a plain
37
+ textarea — its body is not HTML.
38
+ </span>
39
+ </div>
40
+ </div>
41
+
42
+ <div class="rounded-lg border border-slate-200 bg-white p-5">
43
+ <%= form.label :body, "Body (Liquid)", class: label_class %>
44
+
45
+ <%# --- format: html -> Lexxy ------------------------------------------------------
46
+ `lexxy_rich_text_area_tag` is the TAG-level helper: it takes a raw name and a raw
47
+ String, touches no ActionText model, and needs no `has_rich_text`
48
+ (docs/lexxy-decision.md §1). The element is form-associated
49
+ (`static formAssociated = true` + `ElementInternals.setFormValue`), so it submits
50
+ under its own `name` with no hidden input to sync.
51
+
52
+ The two blank data attributes are load-bearing. The helper assigns them with `||=`,
53
+ which short-circuits — supplying any truthy-or-present value means
54
+ `main_app.rails_direct_uploads_url` and `main_app.rails_service_blob_url` are never
55
+ evaluated, so a host that has not mounted ActiveStorage routes does not blow up here.
56
+ With `attachments: "false"` the JS never reads them either. %>
57
+ <div data-template-editor-target="htmlPane">
58
+ <%= lexxy_rich_text_area_tag "template[body]", form_values["body"],
59
+ id: "template_body",
60
+ attachments: "false",
61
+ class: "lexxy-content aac-lexxy",
62
+ data: {
63
+ direct_upload_url: "",
64
+ blob_url_template: "",
65
+ template_editor_target: "htmlInput",
66
+ action: "lexxy:change->template-editor#bodyChanged"
67
+ } do %>
68
+ <%= render "lexxy_toolbar" %>
69
+ <% end %>
70
+ </div>
71
+
72
+ <%# --- format: text -> plain textarea --------------------------------------------- %>
73
+ <div data-template-editor-target="textPane" hidden>
74
+ <%= text_area_tag "template[body]", form_values["body"],
75
+ id: "template_body_text",
76
+ rows: 14,
77
+ spellcheck: false,
78
+ class: field_class("font-mono text-sm"),
79
+ data: {
80
+ template_editor_target: "textInput",
81
+ action: "input->template-editor#bodyChanged"
82
+ } %>
83
+ </div>
84
+
85
+ <p class="<%= hint_class %>">
86
+ Liquid tags such as <code>{{ result.value | currency: '$' }}</code> and
87
+ <code>{% if %}</code> are plain text to the editor. Splitting one across bold or italic
88
+ breaks it — the preview below parses in strict mode and will say so, with a line number.
89
+ </p>
90
+ </div>
91
+
92
+ <div class="flex items-center gap-3">
93
+ <%= form.submit "Publish version", class: button_class(:primary) %>
94
+
95
+ <%# Same form, different action: preview posts the body being typed to a renderer that
96
+ needs no persisted row, so there is no "save a draft first" step.
97
+
98
+ `formnovalidate` so a preview works before `key` has been filled in — without it,
99
+ `requestSubmit` from the debounced auto-preview silently fails the required-field
100
+ check and the pane never updates.
101
+
102
+ This button is a SIBLING of the editors, and the frame it targets is a sibling too.
103
+ Keep it that way: if `template_preview` ever wraps the editor, Turbo tears down and
104
+ rebuilds <lexxy-editor> on every preview and the operator loses cursor position and
105
+ the entire undo stack. %>
106
+ <%= form.button "Preview", formaction: preview_templates_path, formmethod: :post,
107
+ formnovalidate: true, name: nil,
108
+ class: button_class(:secondary),
109
+ data: {
110
+ turbo_frame: "template_preview",
111
+ template_editor_target: "previewButton"
112
+ } %>
113
+
114
+ <span class="text-xs text-slate-500">Preview refreshes as you type.</span>
115
+ </div>
116
+ <% end %>
117
+
118
+ <div class="mt-6">
119
+ <%= turbo_frame_tag "template_preview" do %>
120
+ <div class="rounded-lg border border-dashed border-slate-300 bg-white p-5 text-sm text-slate-500">
121
+ Preview appears here.
122
+ </div>
123
+ <% end %>
124
+ </div>