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,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
|