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,39 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Client-side filtering for the formula/template/lookup-table index tables.
4
+ //
5
+ // Purely a view over rows already on the page — it never re-queries. The server-side `key`
6
+ // and `scope` filters (ApplicationController#filter_by_key_and_scope) remain the real ones,
7
+ // and this is the "I can see it, let me narrow it" affordance on top of them.
8
+ export default class extends Controller {
9
+ static targets = [ "query", "row", "empty", "count" ]
10
+
11
+ connect() {
12
+ this.filter()
13
+ }
14
+
15
+ filter() {
16
+ const needle = this.hasQueryTarget ? this.queryTarget.value.trim().toLowerCase() : ""
17
+ let visible = 0
18
+
19
+ for (const row of this.rowTargets) {
20
+ const haystack = (row.dataset.filterText || row.textContent).toLowerCase()
21
+ const match = needle === "" || haystack.includes(needle)
22
+
23
+ row.hidden = !match
24
+ if (match) visible++
25
+ }
26
+
27
+ if (this.hasEmptyTarget) this.emptyTarget.hidden = visible > 0 || this.rowTargets.length === 0
28
+ if (this.hasCountTarget) {
29
+ this.countTarget.textContent = needle === ""
30
+ ? `${this.rowTargets.length}`
31
+ : `${visible} of ${this.rowTargets.length}`
32
+ }
33
+ }
34
+
35
+ clear() {
36
+ if (this.hasQueryTarget) this.queryTarget.value = ""
37
+ this.filter()
38
+ }
39
+ }
@@ -0,0 +1,86 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // The template `body` field: a Lexxy editor for `format: "html"`, a plain textarea for
4
+ // `format: "text"` (docs/lexxy-decision.md §4b — a text template's body is not HTML, and
5
+ // putting it through an HTML editor would be the same mistake the formula field avoids).
6
+ //
7
+ // Both panes are rendered once and stay in the DOM. Switching format swaps which one is
8
+ // visible and which one carries the `name` attribute; the hidden one has its `name` removed
9
+ // so the browser does not submit two values for `template[body]`. Removing `name` works
10
+ // identically for the textarea and for `<lexxy-editor>`, which is a form-associated custom
11
+ // element reading `this.getAttribute("name")` — no value, no submission.
12
+ //
13
+ // Neither pane is ever inside the `template_preview` Turbo Frame. The preview button posts
14
+ // the main form with `formaction`, so the frame that gets replaced is a sibling of the
15
+ // editor, never an ancestor. Nesting them would make Turbo tear down and rebuild
16
+ // <lexxy-editor> on every preview, losing cursor position and the whole undo stack.
17
+ export default class extends Controller {
18
+ static targets = [ "htmlPane", "textPane", "htmlInput", "textInput", "format", "previewButton" ]
19
+
20
+ static values = {
21
+ previewDelay: { type: Number, default: 700 },
22
+ autoPreview: { type: Boolean, default: true }
23
+ }
24
+
25
+ connect() {
26
+ this.previewTimer = null
27
+ this.applyFormat()
28
+ }
29
+
30
+ disconnect() {
31
+ clearTimeout(this.previewTimer)
32
+ }
33
+
34
+ get format() {
35
+ return this.hasFormatTarget ? this.formatTarget.value : "html"
36
+ }
37
+
38
+ applyFormat() {
39
+ const html = this.format === "html"
40
+
41
+ this.togglePane(this.htmlPaneTarget, this.htmlInputTarget, html)
42
+ this.togglePane(this.textPaneTarget, this.textInputTarget, !html)
43
+ }
44
+
45
+ togglePane(pane, input, active) {
46
+ pane.hidden = !active
47
+
48
+ if (active) {
49
+ input.setAttribute("name", "template[body]")
50
+ } else {
51
+ // Not `disabled`: `<lexxy-editor>` manages its own ElementInternals validity state and
52
+ // a disabled form-associated element is skipped by the browser, but removing the name
53
+ // is the behaviour both elements agree on without relying on that.
54
+ input.removeAttribute("name")
55
+ }
56
+ }
57
+
58
+ formatChanged() {
59
+ this.applyFormat()
60
+ this.schedulePreview()
61
+ }
62
+
63
+ // Fires from `input` on the textarea and from Lexxy's own `lexxy:change` event. Debounced,
64
+ // because the preview goes through the real Liquid sandbox server-side.
65
+ bodyChanged() {
66
+ this.schedulePreview()
67
+ }
68
+
69
+ schedulePreview() {
70
+ if (!this.autoPreviewValue || !this.hasPreviewButtonTarget) return
71
+
72
+ clearTimeout(this.previewTimer)
73
+ this.previewTimer = setTimeout(() => this.preview(), this.previewDelayValue)
74
+ }
75
+
76
+ preview() {
77
+ const form = this.element.closest("form")
78
+ if (!form) return
79
+
80
+ // `requestSubmit(submitter)` rather than `submit()`: it honours the button's `formaction`
81
+ // (POST /templates/preview), its `formmethod`, and its `data-turbo-frame`, so Turbo
82
+ // replaces only the preview frame. `form.submit()` would ignore all three and publish
83
+ // the template.
84
+ form.requestSubmit(this.previewButtonTarget)
85
+ }
86
+ }
@@ -0,0 +1,2 @@
1
+ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
2
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-500:oklch(63.7% .237 25.331);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-sky-200:oklch(90.1% .058 230.902);--color-sky-800:oklch(44.3% .11 240.79);--color-violet-700:oklch(49.1% .27 292.581);--color-pink-700:oklch(52.5% .223 3.958);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-slate-950:oklch(12.9% .042 264.695);--color-white:#fff;--spacing:.25rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-wide:.025em;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1)}}@layer base{.acts-as-calculator-editor{-webkit-text-size-adjust:100%;margin:0;font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;line-height:1.5}.acts-as-calculator-editor *,.acts-as-calculator-editor :before,.acts-as-calculator-editor :after{box-sizing:border-box}.acts-as-calculator-editor h1,.acts-as-calculator-editor h2,.acts-as-calculator-editor h3{font-size:inherit;font-weight:inherit;margin:0}.acts-as-calculator-editor p,.acts-as-calculator-editor ul,.acts-as-calculator-editor ol,.acts-as-calculator-editor pre,.acts-as-calculator-editor figure{margin:0}.acts-as-calculator-editor ul,.acts-as-calculator-editor ol{padding:0;list-style:none}.acts-as-calculator-editor fieldset{min-width:0;margin:0}.acts-as-calculator-editor a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}.acts-as-calculator-editor table{border-collapse:collapse;text-indent:0}.acts-as-calculator-editor code,.acts-as-calculator-editor pre{font-family:var(--aac-mono);font-size:1em}.acts-as-calculator-editor button,.acts-as-calculator-editor input,.acts-as-calculator-editor select,.acts-as-calculator-editor textarea{font:inherit;color:inherit;margin:0}.acts-as-calculator-editor button{cursor:pointer;background:0 0}.acts-as-calculator-editor [hidden]{display:none!important}}@layer components{.acts-as-calculator-editor{--aac-mono:ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace}.aac-expression__shell{border:1px solid var(--color-slate-300);background-color:#fff;border-radius:.375rem;position:relative;box-shadow:0 1px 2px #0000000d}.aac-expression__shell:focus-within{border-color:var(--color-slate-500);box-shadow:0 0 0 2px var(--color-slate-200)}.aac-expression__overlay,.aac-expression__ink{font-family:var(--aac-mono);letter-spacing:0;tab-size:2;white-space:pre-wrap;overflow-wrap:break-word;word-break:normal;border:0;margin:0;padding:.625rem .75rem;font-size:.875rem;line-height:1.5rem}.aac-expression__overlay{pointer-events:none;color:var(--color-slate-900);background:0 0;position:absolute;inset:0;overflow:hidden}.aac-expression__ink{resize:vertical;color:#0000;width:100%;caret-color:var(--color-slate-900);scrollbar-width:none;background:0 0;outline:none;display:block;position:relative}.aac-expression__ink::-webkit-scrollbar{display:none}.aac-expression__ink::selection{background:var(--color-sky-200);color:#0000}.aac-tok--function{color:var(--color-violet-700);font-weight:600}.aac-tok--identifier{color:var(--color-sky-800)}.aac-tok--number{color:var(--color-amber-700)}.aac-tok--string{color:var(--color-emerald-700)}.aac-tok--literal{color:var(--color-amber-700);font-weight:600}.aac-tok--operator{color:var(--color-pink-700)}.aac-tok--paren,.aac-tok--comma{color:var(--color-slate-500)}.aac-tok--undeclared,.aac-tok--unknown-function{-webkit-text-decoration:underline wavy var(--color-amber-500);-webkit-text-decoration:underline wavy var(--color-amber-500);text-decoration:underline wavy var(--color-amber-500);text-underline-offset:3px}.aac-tok--unterminated-string{color:var(--color-red-700);-webkit-text-decoration:underline wavy var(--color-red-500);-webkit-text-decoration:underline wavy var(--color-red-500);text-decoration:underline wavy var(--color-red-500);text-underline-offset:3px}.aac-suggestions{z-index:30;border:1px solid var(--color-slate-200);background-color:#fff;border-radius:.375rem;min-width:16rem;max-height:15rem;margin-top:.25rem;padding:.25rem;position:absolute;top:100%;left:0;overflow-y:auto;box-shadow:0 10px 15px -3px #0000001a}.aac-suggestion{cursor:pointer;border-radius:.25rem;justify-content:space-between;align-items:center;gap:1rem;padding:.25rem .5rem;font-size:.8125rem;display:flex}.aac-suggestion.is-active{background-color:var(--color-slate-100)}.aac-suggestion__name{font-family:var(--aac-mono);color:var(--color-slate-900)}.aac-suggestion__kind{text-transform:uppercase;letter-spacing:.05em;color:var(--color-slate-400);font-size:.6875rem}.aac-status{border-radius:.375rem;padding:.375rem .625rem;font-size:.8125rem}.aac-status--ok{background-color:var(--color-emerald-50);color:var(--color-emerald-800)}.aac-status--error{background-color:var(--color-red-50);color:var(--color-red-800)}.aac-status--warning{background-color:var(--color-amber-50);color:var(--color-amber-800)}.aac-status--unknown{background-color:var(--color-slate-100);color:var(--color-slate-600)}.acts-as-calculator-editor lexxy-editor.aac-lexxy{border:1px solid var(--color-slate-300);background-color:#fff;border-radius:.375rem;min-height:18rem;display:block}.acts-as-calculator-editor lexxy-editor.aac-lexxy:focus-within{border-color:var(--color-slate-500);box-shadow:0 0 0 2px var(--color-slate-200)}}@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.right-0{right:0}.z-20{z-index:20}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.h-64{height:calc(var(--spacing) * 64)}.min-h-screen{min-height:100vh}.w-40{width:calc(var(--spacing) * 40)}.w-56{width:calc(var(--spacing) * 56)}.w-80{width:calc(var(--spacing) * 80)}.w-full{width:100%}.max-w-6xl{max-width:var(--container-6xl)}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.cursor-pointer{cursor:pointer}.list-none{list-style-type:none}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:var(--spacing)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-4{row-gap:calc(var(--spacing) * 4)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-slate-100>:not(:last-child)){border-color:var(--color-slate-100)}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-amber-200{border-color:var(--color-amber-200)}.border-emerald-200{border-color:var(--color-emerald-200)}.border-red-200{border-color:var(--color-red-200)}.border-red-300{border-color:var(--color-red-300)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-300{border-color:var(--color-slate-300)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-100{background-color:var(--color-amber-100)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-red-50{background-color:var(--color-red-50)}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-slate-900{background-color:var(--color-slate-900)}.bg-white{background-color:var(--color-white)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-8{padding-block:calc(var(--spacing) * 8)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.text-amber-800{color:var(--color-amber-800)}.text-amber-900{color:var(--color-amber-900)}.text-emerald-800{color:var(--color-emerald-800)}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-red-900{color:var(--color-red-900)}.text-slate-100{color:var(--color-slate-100)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-slate-900{color:var(--color-slate-900)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.italic{font-style:italic}.underline{text-decoration-line:underline}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.placeholder\:text-slate-400::placeholder{color:var(--color-slate-400)}.read-only\:bg-slate-100:read-only{background-color:var(--color-slate-100)}@media (hover:hover){.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:bg-slate-100:hover{background-color:var(--color-slate-100)}.hover\:bg-slate-700:hover{background-color:var(--color-slate-700)}.hover\:text-slate-900:hover{color:var(--color-slate-900)}.hover\:text-slate-950:hover{color:var(--color-slate-950)}}.focus\:border-slate-500:focus{border-color:var(--color-slate-500)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-red-300:focus{--tw-ring-color:var(--color-red-300)}.focus\:ring-slate-200:focus{--tw-ring-color:var(--color-slate-200)}.focus\:ring-slate-300:focus{--tw-ring-color:var(--color-slate-300)}.focus\:ring-slate-400:focus{--tw-ring-color:var(--color-slate-400)}.focus\:ring-offset-1:focus{--tw-ring-offset-width:1px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:bg-slate-100:disabled{background-color:var(--color-slate-100)}@media (min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:col-span-3{grid-column:span 3/span 3}.sm\:col-span-4{grid-column:span 4/span 4}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculatorEditor
4
+ # This engine calls the core gem's Decrees in-process rather than over HTTP
5
+ # (docs/core-gem-contract.md §1), which means it also inherits the job the core gem's own
6
+ # `ApplicationController` was doing: turning `ActsAsCalculator::Error` into a status code.
7
+ # The mapping below is the same table, deliberately — a divergence here would mean the
8
+ # same failure reads as a different kind of problem depending on which door you came in.
9
+ #
10
+ # There is no authentication or authorization here, matching the core gem: a host wraps
11
+ # the mounted engine in its own.
12
+ class ApplicationController < ActionController::Base
13
+ # Registration order is inverted at lookup time — the most recently registered matching
14
+ # handler wins — so the catch-all for the gem's own errors goes first and the narrower
15
+ # cases override it below.
16
+ rescue_from ActsAsCalculator::Error, with: :render_unprocessable
17
+ rescue_from ActsAsCalculator::FormulaNotFoundError,
18
+ ActsAsCalculator::TemplateNotFoundError,
19
+ ActsAsCalculator::NoEffectiveVersionError, with: :render_not_found
20
+ rescue_from ActsAsCalculator::PartialSupersedeError, with: :render_conflict
21
+ rescue_from ActsAsCalculator::LookupTableInUseError, with: :render_conflict
22
+ rescue_from ::ActiveRecord::RecordNotFound, with: :render_not_found
23
+ rescue_from ::ActiveRecord::RecordInvalid, with: :render_unprocessable
24
+ rescue_from ::ActiveRecord::RecordNotDestroyed, with: :render_conflict
25
+ rescue_from ::ActionController::ParameterMissing, with: :render_bad_request
26
+
27
+ private
28
+
29
+ # 409, not 422: the request is well-formed and the state is valid — the two are just
30
+ # irreconcilable until the operator says which one they meant to keep.
31
+ CONFLICTS = [ActsAsCalculator::PartialSupersedeError,
32
+ ActsAsCalculator::LookupTableInUseError,
33
+ ::ActiveRecord::RecordNotDestroyed].freeze
34
+
35
+ NOT_FOUND = [ActsAsCalculator::FormulaNotFoundError,
36
+ ActsAsCalculator::TemplateNotFoundError,
37
+ ActsAsCalculator::NoEffectiveVersionError,
38
+ ::ActiveRecord::RecordNotFound].freeze
39
+
40
+ # Anything a form can provoke. `RecordNotFound` is not in here on purpose: a missing row
41
+ # is not something re-rendering a form can help with, so it falls through to the
42
+ # `rescue_from` above and gets a 404 page.
43
+ RENDERABLE = [ActsAsCalculator::Error,
44
+ ::ActiveRecord::RecordInvalid,
45
+ ::ActiveRecord::RecordNotDestroyed].freeze
46
+
47
+ # Wraps the one Decree call an action exists to make, so a failure re-renders the form
48
+ # the operator was looking at — with the real status code, not a 302 that loses it.
49
+ def rescuing_core_errors(view, message: nil)
50
+ yield
51
+ rescue *RENDERABLE => e
52
+ @error_message = message || editor_message(e)
53
+ render view, status: editor_status(e)
54
+ end
55
+
56
+ def editor_status(error)
57
+ return :conflict if CONFLICTS.any? { |type| error.is_a?(type) }
58
+ return :not_found if NOT_FOUND.any? { |type| error.is_a?(type) }
59
+
60
+ :unprocessable_entity
61
+ end
62
+
63
+ # `PartialSupersedeError` and `LookupTableInUseError` carry messages written for an
64
+ # operator — they name the exact date range that would be orphaned, or the formula
65
+ # versions blocking a bracket edit, and both suggest a fix. They are shown verbatim.
66
+ # `RecordNotDestroyed`, by contrast, says "Failed to destroy the record", so the record's
67
+ # own validation errors are tried first and the caller may pass something better.
68
+ def editor_message(error)
69
+ record = error.respond_to?(:record) ? error.record : nil
70
+ record&.errors&.full_messages&.to_sentence.presence || error.message
71
+ end
72
+
73
+ def render_conflict(error)
74
+ render_error(error, :conflict)
75
+ end
76
+
77
+ def render_not_found(error)
78
+ render_error(error, :not_found)
79
+ end
80
+
81
+ def render_unprocessable(error)
82
+ render_error(error, :unprocessable_entity)
83
+ end
84
+
85
+ def render_bad_request(error)
86
+ render_error(error, :bad_request)
87
+ end
88
+
89
+ def render_error(error, status)
90
+ @error_message = editor_message(error)
91
+ @error_type = error.class.name
92
+
93
+ respond_to do |format|
94
+ format.html { render "acts_as_calculator_editor/shared/error", status: }
95
+ format.json { render json: { error: { type: @error_type, message: @error_message } }, status: }
96
+ end
97
+ end
98
+
99
+ # `key` alone never identifies a row in this gem — every uniqueness constraint scopes
100
+ # past it to `[key, scope, owner]` (contract §2.2) — so a list filters on the pair and
101
+ # every index view shows `scope` as a first-class column.
102
+ def filter_by_key_and_scope(relation)
103
+ relation = relation.where(key: params[:key]) if params[:key].present?
104
+ relation = relation.where(scope: params[:scope]) if params[:scope].present?
105
+ relation
106
+ end
107
+
108
+ # v1 authors global (nil-owner) rows only. See `ActsAsCalculatorEditor.owner_classes`
109
+ # for why there is no free-text owner field, and what a host does instead.
110
+ def current_owner
111
+ nil
112
+ end
113
+ helper_method :current_owner
114
+ end
115
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculatorEditor
4
+ # Export is a gap the core gem leaves open: it reads the import document schema and has no
5
+ # reverse (docs/core-gem-contract.md §4.2). What comes out here is that *import* shape —
6
+ # not the shape `SerializeFormula` and friends emit, which differ in four ways that would
7
+ # each quietly produce a file that does not round-trip.
8
+ #
9
+ # `?full_history=true` widens the formulas section from active versions to every version.
10
+ # It does not widen templates, which stay current-only for the reason `ExportTemplates`
11
+ # explains.
12
+ class ExportsController < ApplicationController
13
+ def show
14
+ document = BuildExportDocument.(key: params[:key], scope: params[:scope],
15
+ full_history: full_history?)
16
+
17
+ send_data JSON.pretty_generate(document),
18
+ filename:, type: "application/json", disposition: disposition
19
+ end
20
+
21
+ private
22
+
23
+ def full_history?
24
+ %w[true 1].include?(params[:full_history].to_s)
25
+ end
26
+
27
+ def filename
28
+ parts = ["acts_as_calculator", params[:scope].presence, params[:key].presence,
29
+ Date.current.strftime("%Y%m%d")].compact
30
+
31
+ "#{parts.join("-").parameterize}.json"
32
+ end
33
+
34
+ # `inline` so a link with `?disposition=inline` can be read in a browser tab; a plain
35
+ # export link downloads. Turbo does not intercept either — a file response is not a
36
+ # navigation, which is why the export link in the views sets `data-turbo="false"`.
37
+ def disposition
38
+ params[:disposition].to_s == "inline" ? "inline" : "attachment"
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculatorEditor
4
+ # No edit, no update, no destroy — a published version is immutable, and the corrective
5
+ # action is publishing another one (docs/core-gem-contract.md §2.4). `new` accepts
6
+ # `?from_version_id=` so "duplicate this into a new draft" is available, which is usually
7
+ # what an operator reaching for "edit" actually means.
8
+ #
9
+ # `@form` and `@variable_rows` are plain hashes rather than an unsaved `FormulaVersion`:
10
+ # a failed submission has to come back showing exactly what the operator typed, including
11
+ # the unparseable date that caused the failure, and an ActiveRecord object cannot hold one.
12
+ class FormulaVersionsController < ApplicationController
13
+ FIELDS = %w[expression effective_from effective_to status change_note].freeze
14
+
15
+ def index
16
+ @formula = formula
17
+ @versions = formula.versions.includes(:variables).order(:version_number)
18
+ end
19
+
20
+ def show
21
+ @formula = formula
22
+ @version = version
23
+ @variables = version.variables.order(:name)
24
+ end
25
+
26
+ def new
27
+ @formula = formula
28
+ @form = prefilled_form
29
+ @variable_rows = prefilled_variable_rows
30
+ @active_versions = active_versions
31
+ end
32
+
33
+ # Straight through `PublishFormulaVersion`, which is the same object the core gem's own
34
+ # API controller calls: the incumbent is superseded rather than rewritten, version
35
+ # numbers are handed out in one place, and a supersede that would leave part of the
36
+ # incumbent's range with no active version raises `PartialSupersedeError` — surfaced
37
+ # here as a 409 with its message intact, because that message names the exact date range
38
+ # at issue and the two ways out of it.
39
+ def create
40
+ @formula = formula
41
+ @form = submitted_form
42
+ @variable_rows = variables
43
+ @active_versions = active_versions
44
+
45
+ rescuing_core_errors(:new) do
46
+ published = ActsAsCalculator::PublishFormulaVersion.(formula: @formula, **version_attributes)
47
+ redirect_to formula_version_path(@formula, published),
48
+ notice: "Published version #{published.version_number} of #{@formula.key}."
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def formula
55
+ @formula ||= ActsAsCalculator::Formula.find(params[:formula_id])
56
+ end
57
+
58
+ def version
59
+ @version ||= formula.versions.find(params[:id])
60
+ end
61
+
62
+ # The overlap rule is discoverable from data already in hand, so the form shows the
63
+ # ranges already in force rather than letting the operator find out by 409 (contract
64
+ # §2.3).
65
+ def active_versions
66
+ formula.versions.active.order(:effective_from, :version_number)
67
+ end
68
+
69
+ def duplicated
70
+ return @duplicated if defined?(@duplicated)
71
+
72
+ @duplicated = params[:from_version_id].presence && formula.versions.find(params[:from_version_id])
73
+ end
74
+
75
+ # A duplicate comes back as a `draft` and without the dates: the whole reason to
76
+ # duplicate is that the new version applies to a different period, and copying the old
77
+ # `effective_from` into an active version is the fastest route to a supersede nobody
78
+ # meant.
79
+ def prefilled_form
80
+ return blank_form if duplicated.nil?
81
+
82
+ blank_form.merge("expression" => duplicated.expression,
83
+ "change_note" => duplicated.change_note,
84
+ "status" => ActsAsCalculator::FormulaVersion::DRAFT)
85
+ end
86
+
87
+ def blank_form
88
+ FIELDS.index_with { "" }.merge("status" => ActsAsCalculator::FormulaVersion::ACTIVE)
89
+ end
90
+
91
+ def prefilled_variable_rows
92
+ return [] if duplicated.nil?
93
+
94
+ duplicated.variables.order(:name).map do |variable|
95
+ { "name" => variable.name, "source_type" => variable.source_type,
96
+ "required" => variable.required?, "source_config" => variable.source_config || {} }
97
+ end
98
+ end
99
+
100
+ def submitted_form
101
+ permitted = version_params
102
+
103
+ FIELDS.index_with { |field| permitted[field].to_s }
104
+ end
105
+
106
+ def version_params
107
+ params.require(:version).permit(*FIELDS)
108
+ end
109
+
110
+ # `variables` is read outside strong parameters because `source_config` is arbitrary
111
+ # JSON, which `permit` cannot describe without either enumerating every source type's
112
+ # keys or opening the whole subtree. `CastVariableRows` is the whitelist instead: it
113
+ # emits exactly `name`, `source_type`, `source_config` and `required`, and nothing a
114
+ # form posts alongside them survives.
115
+ def variables
116
+ CastVariableRows.(params.dig(:version, :variables))
117
+ end
118
+
119
+ def version_attributes
120
+ { expression: @form["expression"],
121
+ effective_from: @form["effective_from"],
122
+ effective_to: @form["effective_to"].presence,
123
+ status: @form["status"].presence || ActsAsCalculator::FormulaVersion::ACTIVE,
124
+ change_note: @form["change_note"].presence,
125
+ variables: @variable_rows }
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculatorEditor
4
+ # Identity only — `key` and `scope`. There is no `expression` field anywhere in this
5
+ # controller, because "edit a formula" is not a thing this data model supports: everything
6
+ # mutable about a formula lives on its versions (docs/core-gem-contract.md §2.1), which is
7
+ # what `FormulaVersionsController` is for.
8
+ class FormulasController < ApplicationController
9
+ # The audit trail outranks a delete. `FormulaVersion has_many :runs,
10
+ # dependent: :restrict_with_error`, so this delete was always going to fail — the view
11
+ # says so before the operator clicks it, and `RecordNotDestroyed`'s own message
12
+ # ("Failed to destroy the record") is not worth showing anyone.
13
+ AUDITED = "This formula has versions with recorded calculation runs. The audit trail is " \
14
+ "never deleted to make room for a delete, so the formula stays. Retire its " \
15
+ "active version instead if it should stop being used."
16
+
17
+ def index
18
+ @formulas = filter_by_key_and_scope(ActsAsCalculator::Formula.order(:scope, :key, :id))
19
+ end
20
+
21
+ def show
22
+ @formula = formula
23
+ @versions = versions
24
+ end
25
+
26
+ def new
27
+ @formula = ActsAsCalculator::Formula.new(scope: ActsAsCalculator::DEFAULT_SCOPE)
28
+ end
29
+
30
+ def edit
31
+ @formula = formula
32
+ end
33
+
34
+ # `Formula.create!` rather than a Decree: identity rows have no effective-dating, no
35
+ # supersede and no version numbering to get wrong, and the core gem's own controller
36
+ # creates them the same way. The Decree rule exists for `calculator_formula_versions`,
37
+ # `calculator_templates` and `calculator_lookup_table_entries` — the tables where a
38
+ # hand-rolled write would bypass real logic.
39
+ def create
40
+ @formula = ActsAsCalculator::Formula.new(**formula_attributes)
41
+
42
+ rescuing_core_errors(:new) do
43
+ @formula.save!
44
+ redirect_to formula_path(@formula), notice: "Created formula #{@formula.key}."
45
+ end
46
+ end
47
+
48
+ # Re-owning or re-scoping a formula silently re-targets every future calculation of that
49
+ # key (contract §2.7). v1 exposes `key` and `scope` only, and no owner field at all.
50
+ def update
51
+ @formula = formula
52
+
53
+ rescuing_core_errors(:edit) do
54
+ @formula.update!(**formula_attributes)
55
+ redirect_to formula_path(@formula), notice: "Updated formula #{@formula.key}."
56
+ end
57
+ end
58
+
59
+ def destroy
60
+ formula.destroy!
61
+ redirect_to formulas_path, notice: "Deleted formula #{formula.key}."
62
+ rescue ::ActiveRecord::RecordNotDestroyed
63
+ @formula = formula
64
+ @versions = versions
65
+ @error_message = AUDITED
66
+ render :show, status: :conflict
67
+ end
68
+
69
+ private
70
+
71
+ # `find` by id, not by key: `key` alone never identifies a row here (contract §2.2).
72
+ def formula
73
+ @formula ||= ActsAsCalculator::Formula.find(params[:id])
74
+ end
75
+
76
+ def versions
77
+ formula.versions.includes(:variables).order(:version_number)
78
+ end
79
+
80
+ def formula_params
81
+ params.require(:formula).permit(:key, :scope)
82
+ end
83
+
84
+ # Only the keys that were sent, so a rename cannot blank the scope — the same rule the
85
+ # core gem's `PATCH /formulas/:id` follows.
86
+ def formula_attributes
87
+ permitted = formula_params
88
+ attributes = {}
89
+ attributes[:key] = permitted[:key].to_s.strip if permitted.key?(:key)
90
+ attributes[:scope] = permitted[:scope].presence || ActsAsCalculator::DEFAULT_SCOPE if permitted.key?(:scope)
91
+ attributes
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsCalculatorEditor
4
+ # The core gem has no controller, no routes and no serializer for lookup tables
5
+ # (docs/core-gem-contract.md §4.4) — deliberately, because `POST /import` is the only path
6
+ # that enforces the guard against editing brackets an audited formula version resolved to.
7
+ # This controller is therefore the only lookup-table authoring surface there is, and it
8
+ # keeps that property by writing exclusively through `ImportLookupTable`
9
+ # (via `PublishLookupTable`) rather than touching `entries` directly.
10
+ #
11
+ # This matters because a lookup table is not decoration: a variable with
12
+ # `source_type: "lookup"` cannot evaluate without one.
13
+ class LookupTablesController < ApplicationController
14
+ def index
15
+ @lookup_tables = filter_by_key_and_scope(
16
+ ActsAsCalculator::LookupTable.includes(:entries).order(:scope, :key, :id)
17
+ )
18
+ end
19
+
20
+ def show
21
+ @lookup_table = lookup_table
22
+ @entries = entries
23
+ @used_by = references
24
+ end
25
+
26
+ def new
27
+ @form = { "key" => "", "scope" => ActsAsCalculator::DEFAULT_SCOPE }
28
+ @entry_rows = []
29
+ end
30
+
31
+ # `key` and `scope` are not editable. `ImportLookupTable` keys on `[key, scope, owner]`,
32
+ # so a "rename" would silently create a second table and leave every formula variable
33
+ # still pointing at the old one — which would keep calculating, against brackets the
34
+ # operator believes they just changed. Entries are what an edit changes; a new key is a
35
+ # new table.
36
+ def edit
37
+ @lookup_table = lookup_table
38
+ @form = { "key" => lookup_table.key, "scope" => lookup_table.scope }
39
+ @entry_rows = entries.map { |entry| entry_row(entry) }
40
+ end
41
+
42
+ def create
43
+ @form = submitted_form
44
+ @entry_rows = submitted_entry_rows
45
+
46
+ rescuing_core_errors(:new) do
47
+ created = PublishLookupTable.(key: @form["key"], scope: @form["scope"],
48
+ owner: current_owner, entries: @entry_rows)
49
+ redirect_to lookup_table_path(created), notice: "Created lookup table #{created.key}."
50
+ end
51
+ end
52
+
53
+ def update
54
+ @lookup_table = lookup_table
55
+ @form = { "key" => lookup_table.key, "scope" => lookup_table.scope }
56
+ @entry_rows = submitted_entry_rows
57
+
58
+ rescuing_core_errors(:edit) do
59
+ updated = PublishLookupTable.(key: @form["key"], scope: @form["scope"],
60
+ owner: lookup_table.owner, entries: @entry_rows)
61
+ redirect_to lookup_table_path(updated), notice: "Updated lookup table #{updated.key}."
62
+ end
63
+ end
64
+
65
+ def destroy
66
+ @lookup_table = lookup_table
67
+ @entries = entries
68
+ @used_by = references
69
+
70
+ rescuing_core_errors(:show) do
71
+ DeleteLookupTable.(lookup_table: @lookup_table)
72
+ redirect_to lookup_tables_path, notice: "Deleted lookup table #{@lookup_table.key}."
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def lookup_table
79
+ @lookup_table ||= ActsAsCalculator::LookupTable.find(params[:id])
80
+ end
81
+
82
+ def entries
83
+ lookup_table.entries.ordered
84
+ end
85
+
86
+ # Which formula versions would resolve to *this* table if they ran now — owner shadowing
87
+ # included, which is why this is a Decree call and not a key comparison. Shown on the
88
+ # page so the operator learns an edit is blocked before attempting it.
89
+ def references
90
+ ActsAsCalculator::FindLookupTableReferences.(lookup_table:)
91
+ end
92
+
93
+ def entry_row(entry)
94
+ { "from" => CastExportDecimal.(entry.from), "to" => CastExportDecimal.(entry.to),
95
+ "value" => CastExportDecimal.(entry.value) }
96
+ end
97
+
98
+ def submitted_form
99
+ permitted = params.require(:lookup_table).permit(:key, :scope)
100
+
101
+ { "key" => permitted[:key].to_s, "scope" => permitted[:scope].to_s }
102
+ end
103
+
104
+ # `require` rather than `dig`: an empty entries list is a legitimate edit (an operator
105
+ # clearing every bracket), so a request that forgot the whole `lookup_table` key has to
106
+ # be told apart from one that meant it — otherwise a malformed PATCH silently wipes a
107
+ # table's brackets.
108
+ def submitted_entry_rows
109
+ CastEntryRows.(params.require(:lookup_table)[:entries])
110
+ end
111
+ end
112
+ end